From d41c51bae4398b1397a6008075e2a2b53ff1bea8 Mon Sep 17 00:00:00 2001 From: Devid HW Date: Sun, 19 Apr 2026 08:11:17 +0700 Subject: [PATCH] feat: add get_active_account and 8 new utility tools High Priority Tools: - check_symbol_data_status: validate symbol history data availability - get_backtest_history: list all backtest results for EA/symbol - compare_backtests: side-by-side comparison of multiple backtests Medium Priority Tools: - init_project: scaffold new MQL5 project with templates (scalper/swing/grid) - validate_ea_syntax: pre-compile syntax checking - check_mt5_status: check MT5 terminal readiness Low Priority Tools: - create_set_template: generate .set files from EA inputs - export_report: export reports to CSV/JSON/Markdown Other Changes: - Consolidate config into src/models/config.rs, delete src/config.rs - Add CurrentAccount with UTF-16LE parsing for common.ini - Add BacktestPreflight struct for pre-flight checks - Update backtest handler with active account context - Update list_symbols to filter by active server Total tools: 57 --- src/config.rs | 141 ------ src/main.rs | 1 - src/models/config.rs | 208 +++++++- src/models/mod.rs | 2 +- src/mt5.rs | 4 +- src/tools/definitions/experts.rs | 98 ++++ src/tools/definitions/mod.rs | 16 + src/tools/definitions/system.rs | 10 + src/tools/definitions/utility.rs | 167 +++++++ src/tools/handlers/backtest.rs | 134 ++++- src/tools/handlers/experts.rs | 316 +++++++++++- src/tools/handlers/mod.rs | 17 + src/tools/handlers/system.rs | 73 ++- src/tools/handlers/utility.rs | 807 +++++++++++++++++++++++++++++++ tests/e2e_all_tools.sh | 144 ++++++ 15 files changed, 1959 insertions(+), 179 deletions(-) delete mode 100644 src/config.rs create mode 100644 src/tools/definitions/utility.rs create mode 100644 src/tools/handlers/utility.rs create mode 100755 tests/e2e_all_tools.sh diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index e28bbce..0000000 --- a/src/config.rs +++ /dev/null @@ -1,141 +0,0 @@ -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::path::Path; - -#[allow(dead_code)] -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Config { - pub wine_executable: Option, - pub terminal_dir: Option, - pub experts_dir: Option, - pub tester_profiles_dir: Option, - pub tester_cache_dir: Option, - pub display_mode: Option, - pub backtest_symbol: Option, - pub backtest_deposit: Option, - pub backtest_currency: Option, - pub backtest_leverage: Option, - pub backtest_model: Option, - pub backtest_timeframe: Option, - pub backtest_timeout: Option, - pub opt_log_dir: Option, - pub opt_min_agents: Option, - pub reports_dir: Option, -} - -impl Default for Config { - fn default() -> Self { - Self { - wine_executable: None, - terminal_dir: None, - experts_dir: None, - tester_profiles_dir: None, - tester_cache_dir: None, - display_mode: None, - backtest_symbol: None, - backtest_deposit: None, - backtest_currency: None, - backtest_leverage: None, - backtest_model: None, - backtest_timeframe: None, - backtest_timeout: None, - opt_log_dir: None, - opt_min_agents: None, - reports_dir: None, - } - } -} - -#[allow(dead_code)] -impl Config { - pub fn load() -> Result { - let config_path = Self::get_config_path(); - if !config_path.exists() { - return Ok(Config::default()); - } - - let content = fs::read_to_string(&config_path)?; - let mut config: HashMap = HashMap::new(); - - // Parse simple YAML format (key: value) - 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); - } - } - } - - 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(), - }) - } - - 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 { - // Development mode - use parent directory - Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap_or(Path::new(".")).join("config").join("mt5-quant.yaml") - } - } - } - - 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(), - "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()), - _ => String::new(), - } - } -} diff --git a/src/main.rs b/src/main.rs index 0c205d1..b4e47f3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,6 @@ mod pipeline; mod storage; mod tools; -mod config; mod mcp_server; use anyhow::Result; diff --git a/src/models/config.rs b/src/models/config.rs index 524ab6e..c68087f 100644 --- a/src/models/config.rs +++ b/src/models/config.rs @@ -2,8 +2,80 @@ use anyhow::Result; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; +use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; +/// Active MT5 account session info +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurrentAccount { + pub login: String, + pub server: String, +} + +impl CurrentAccount { + /// Parse common.ini to extract active account info + /// Handles UTF-16LE encoding which MT5 uses on Windows/Wine + pub fn from_common_ini(terminal_dir: &Path) -> Option { + let common_ini = terminal_dir.join("config").join("common.ini"); + if !common_ini.exists() { + return None; + } + + // Try reading as UTF-16LE first (MT5 default encoding) + let bytes = fs::read(&common_ini).ok()?; + let content = if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { + // UTF-16LE BOM detected + let start = if bytes.len() >= 2 { 2 } else { 0 }; + let u16_slice: Vec = bytes[start..] + .chunks(2) + .map(|chunk| { + if chunk.len() == 2 { + u16::from_le_bytes([chunk[0], chunk[1]]) + } else { + chunk[0] as u16 + } + }) + .collect(); + String::from_utf16(&u16_slice).ok()? + } else { + // Try UTF-8 fallback + String::from_utf8(bytes).ok()? + }; + + let mut login = None; + let mut server = None; + + for line in content.lines() { + // Remove null bytes and control characters but keep printable ASCII and valid Unicode + let cleaned: String = line.chars() + .filter(|c| *c != '\0' && !c.is_control()) + .collect(); + + let trimmed = cleaned.trim(); + if trimmed.starts_with("Login=") { + let val = trimmed.strip_prefix("Login=").map(|s| s.trim().to_string()); + if let Some(v) = val { + if !v.is_empty() { + login = Some(v); + } + } + } else if trimmed.starts_with("Server=") { + let val = trimmed.strip_prefix("Server=").map(|s| s.trim().to_string()); + if let Some(v) = val { + if !v.is_empty() { + server = Some(v); + } + } + } + } + + match (login, server) { + (Some(l), Some(s)) => Some(Self { login: l, server: s }), + _ => None, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { pub wine_executable: Option, @@ -73,13 +145,52 @@ impl Config { Ok(discovered) } - /// The canonical writable config location: $MT5_MCP_HOME/config/mt5-quant.yaml - /// or ~/.config/mt5-quant/config/mt5-quant.yaml. + /// The canonical writable config location, checked in order: + /// 1. $MT5_MCP_HOME/config/mt5-quant.yaml (user override) + /// 2. Config next to binary (for portable/development installs) + /// 3. ~/.config/mt5-quant/config/mt5-quant.yaml (standard location) + /// 4. Development fallback (project directory) pub fn writable_config_path() -> PathBuf { + // 1. Check env override first 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") + + // 2. Check if config exists next to the binary + if let Some(binary_dir) = Self::binary_dir() { + let local_config = binary_dir.join("config").join("mt5-quant.yaml"); + if local_config.exists() { + return local_config; + } + + // If binary is in a non-standard path (not system bin), use it + let binary_str = binary_dir.to_string_lossy(); + if !binary_str.starts_with("/usr/local/bin") + && !binary_str.starts_with("/usr/bin") + && !binary_str.starts_with("/bin") + { + return binary_dir.join("config").join("mt5-quant.yaml"); + } + } + + // 3. Check standard location + let standard_config = Self::standard_config_dir().join("config").join("mt5-quant.yaml"); + if standard_config.exists() { + return standard_config; + } + + // 4. Development fallback - use project directory + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let dev_config = manifest_dir.parent() + .unwrap_or(manifest_dir) + .join("config") + .join("mt5-quant.yaml"); + if dev_config.exists() { + return dev_config; + } + + // 5. Fall back to standard location (will be created if not exists) + Self::standard_config_dir().join("config").join("mt5-quant.yaml") } // ── Auto-discovery ──────────────────────────────────────────────────────── @@ -210,6 +321,8 @@ impl Config { wine_executable: {wine}\n\ terminal_dir: {term}\n\ experts_dir: {exp}\n\ + indicators_dir: {ind}\n\ + scripts_dir: {scr}\n\ tester_profiles_dir: {prof}\n\ tester_cache_dir: {cache}\n\ display_mode: {disp}\n\ @@ -227,6 +340,8 @@ impl Config { wine = s(&self.wine_executable), term = s(&self.terminal_dir), exp = s(&self.experts_dir), + ind = s(&self.indicators_dir), + scr = s(&self.scripts_dir), prof = s(&self.tester_profiles_dir), cache = s(&self.tester_cache_dir), disp = s(&self.display_mode), @@ -321,11 +436,40 @@ impl Config { } } - /// Root of the MCP installation: $MT5_MCP_HOME or ~/.config/mt5-quant + /// Root of the MCP installation. + /// Priority: $MT5_MCP_HOME > binary parent dir > ~/.config/mt5-quant pub fn installation_dir() -> PathBuf { + // 1. Check env override first if let Ok(home) = std::env::var("MT5_MCP_HOME") { return Path::new(&home).to_path_buf(); } + + // 2. Check if binary is in a non-standard location (development/portable) + // with an existing config file + if let Some(binary_dir) = Self::binary_dir() { + let binary_str = binary_dir.to_string_lossy(); + let is_system_path = binary_str.starts_with("/usr/local/bin") + || binary_str.starts_with("/usr/bin") + || binary_str.starts_with("/bin"); + + if !is_system_path && binary_dir.join("config").join("mt5-quant.yaml").exists() { + return binary_dir; + } + } + + // 3. Fall back to standard location + Self::standard_config_dir() + } + + /// Get the directory where the current binary is located + fn binary_dir() -> Option { + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|p| p.to_path_buf())) + } + + /// Standard config directory in user's home + fn standard_config_dir() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| Path::new(".").to_path_buf()) .join(".config") @@ -363,7 +507,8 @@ impl Config { /// 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 { + /// If server_filter is provided, only scans that specific server's directory. + pub fn discover_symbols(&self, server_filter: Option<&str>) -> Vec { let mt5_dir = match self.mt5_dir() { Some(d) => d, None => return Vec::new(), @@ -379,6 +524,19 @@ impl Config { // 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 server_name_os = server.file_name(); + let server_name = server_name_os.to_str().unwrap_or(""); + + // Skip non-directory entries and filter by server if specified + if server_name.is_empty() { + continue; + } + if let Some(filter) = server_filter { + if server_name != filter { + continue; + } + } + let history_dir = server.path().join("history"); if !history_dir.is_dir() { continue; @@ -416,4 +574,44 @@ impl Config { sorted.sort(); sorted } + + /// Get the currently active MT5 account from common.ini + pub fn current_account(&self) -> Option { + self.mt5_dir().and_then(|d| CurrentAccount::from_common_ini(&d)) + } + + /// Discover symbols for the currently active account/server only + pub fn discover_symbols_for_active_account(&self) -> Vec { + match self.current_account() { + Some(account) => self.discover_symbols(Some(&account.server)), + None => self.discover_symbols(None), + } + } + + /// Get all available servers that have symbol data + pub fn available_servers(&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 servers = Vec::new(); + if let Ok(entries) = fs::read_dir(&bases_dir) { + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.is_dir() { + if let Some(name) = entry.file_name().to_str() { + servers.push(name.to_string()); + } + } + } + } + servers.sort(); + servers + } } diff --git a/src/models/mod.rs b/src/models/mod.rs index ea9bb1f..f9c268c 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -3,6 +3,6 @@ pub mod deals; pub mod metrics; pub mod report; -pub use config::Config; +pub use config::{Config, CurrentAccount}; pub use deals::Deal; pub use metrics::Metrics; diff --git a/src/mt5.rs b/src/mt5.rs index 39739de..30c3782 100644 --- a/src/mt5.rs +++ b/src/mt5.rs @@ -7,7 +7,7 @@ use std::path::Path; use std::process::Command; use tokio::process::Command as AsyncCommand; -use crate::config::Config; +use crate::models::Config; #[derive(Debug)] pub struct Mt5Manager { @@ -35,7 +35,7 @@ impl Mt5Manager { let mut all_ok = true; // Check config file - let config_path = Config::get_config_path(); + let config_path = Config::writable_config_path(); if config_path.exists() { checks.insert("config_file".to_string(), json!({ "ok": true, diff --git a/src/tools/definitions/experts.rs b/src/tools/definitions/experts.rs index 522da8b..af45935 100644 --- a/src/tools/definitions/experts.rs +++ b/src/tools/definitions/experts.rs @@ -53,3 +53,101 @@ pub fn tool_list_scripts() -> Value { } }) } + +pub fn tool_search_experts() -> Value { + json!({ + "name": "search_experts", + "description": "Search for Expert Advisors by name pattern across MT5 Experts directory and subdirectories", + "inputSchema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Search pattern (case-insensitive substring match)" + } + }, + "required": ["pattern"] + } + }) +} + +pub fn tool_search_indicators() -> Value { + json!({ + "name": "search_indicators", + "description": "Search for indicators by name pattern across MT5 directories and subdirectories", + "inputSchema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Search pattern (case-insensitive substring match)" + }, + "include_builtin": { + "type": "boolean", + "description": "Include built-in MT5 indicators in search", + "default": false + } + }, + "required": ["pattern"] + } + }) +} + +pub fn tool_search_scripts() -> Value { + json!({ + "name": "search_scripts", + "description": "Search for scripts by name pattern across MT5 Scripts directory and subdirectories", + "inputSchema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Search pattern (case-insensitive substring match)" + } + }, + "required": ["pattern"] + } + }) +} + +pub fn tool_copy_indicator_to_project() -> Value { + json!({ + "name": "copy_indicator_to_project", + "description": "Copy an indicator file from MT5 Indicators directory to the project directory", + "inputSchema": { + "type": "object", + "properties": { + "source_path": { + "type": "string", + "description": "Full source path to the indicator file (.mq5 or .ex5)" + }, + "target_name": { + "type": "string", + "description": "Optional: Rename the file (without extension). If not provided, uses original name" + } + }, + "required": ["source_path"] + } + }) +} + +pub fn tool_copy_script_to_project() -> Value { + json!({ + "name": "copy_script_to_project", + "description": "Copy a script file from MT5 Scripts directory to the project directory", + "inputSchema": { + "type": "object", + "properties": { + "source_path": { + "type": "string", + "description": "Full source path to the script file (.mq5 or .ex5)" + }, + "target_name": { + "type": "string", + "description": "Optional: Rename the file (without extension). If not provided, uses original name" + } + }, + "required": ["source_path"] + } + }) +} diff --git a/src/tools/definitions/mod.rs b/src/tools/definitions/mod.rs index fa64005..e782eeb 100644 --- a/src/tools/definitions/mod.rs +++ b/src/tools/definitions/mod.rs @@ -8,6 +8,7 @@ pub mod optimization; pub mod reports; pub mod setfiles; pub mod system; +pub mod utility; pub fn get_tools_list() -> Value { let tools = vec![ @@ -38,10 +39,25 @@ pub fn get_tools_list() -> Value { experts::tool_list_experts(), experts::tool_list_indicators(), experts::tool_list_scripts(), + experts::tool_search_experts(), + experts::tool_search_indicators(), + experts::tool_search_scripts(), + experts::tool_copy_indicator_to_project(), + experts::tool_copy_script_to_project(), // System system::tool_verify_setup(), system::tool_list_symbols(), system::tool_healthcheck(), + system::tool_get_active_account(), + // Utility (8 tools) + utility::tool_check_symbol_data_status(), + utility::tool_get_backtest_history(), + utility::tool_compare_backtests(), + utility::tool_init_project(), + utility::tool_validate_ea_syntax(), + utility::tool_check_mt5_status(), + utility::tool_create_set_template(), + utility::tool_export_report(), // Set Files setfiles::tool_read_set_file(), setfiles::tool_write_set_file(), diff --git a/src/tools/definitions/system.rs b/src/tools/definitions/system.rs index 5173c27..f71d464 100644 --- a/src/tools/definitions/system.rs +++ b/src/tools/definitions/system.rs @@ -35,3 +35,13 @@ pub fn tool_list_symbols() -> Value { } }) } + +pub fn tool_get_active_account() -> Value { + json!({ + "name": "get_active_account", + "description": "Get the currently active MT5 account session info (login, server, available symbols). Use this before backtesting to ensure symbol compatibility.", + "inputSchema": { + "type": "object" + } + }) +} diff --git a/src/tools/definitions/utility.rs b/src/tools/definitions/utility.rs new file mode 100644 index 0000000..8a3123d --- /dev/null +++ b/src/tools/definitions/utility.rs @@ -0,0 +1,167 @@ +use serde_json::{json, Value}; + +pub fn tool_check_symbol_data_status() -> Value { + json!({ + "name": "check_symbol_data_status", + "description": "Validate if a symbol has sufficient historical tick data for a specified date range before running backtest", + "inputSchema": { + "type": "object", + "properties": { + "symbol": { + "type": "string", + "description": "Symbol to check (e.g., 'XAUUSDc')" + }, + "from_date": { + "type": "string", + "description": "Start date in YYYY.MM.DD format" + }, + "to_date": { + "type": "string", + "description": "End date in YYYY.MM.DD format" + } + }, + "required": ["symbol", "from_date", "to_date"] + } + }) +} + +pub fn tool_get_backtest_history() -> Value { + json!({ + "name": "get_backtest_history", + "description": "List all backtests previously run for a specific EA and/or symbol with summary metrics", + "inputSchema": { + "type": "object", + "properties": { + "expert": { + "type": "string", + "description": "EA name to filter by" + }, + "symbol": { + "type": "string", + "description": "Symbol to filter by" + }, + "limit": { + "type": "integer", + "description": "Maximum number of results (default: 10)", + "default": 10 + } + } + } + }) +} + +pub fn tool_compare_backtests() -> Value { + json!({ + "name": "compare_backtests", + "description": "Compare two or more backtest results side-by-side with key metrics analysis", + "inputSchema": { + "type": "object", + "properties": { + "report_dirs": { + "type": "array", + "items": { "type": "string" }, + "description": "List of report directory paths to compare" + } + }, + "required": ["report_dirs"] + } + }) +} + +pub fn tool_init_project() -> Value { + json!({ + "name": "init_project", + "description": "Create a new MQL5 project with standard directory structure and template files", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Project name (will be used for EA filename)" + }, + "template": { + "type": "string", + "enum": ["scalper", "swing", "grid", "basic"], + "description": "Project template type", + "default": "basic" + } + }, + "required": ["name"] + } + }) +} + +pub fn tool_validate_ea_syntax() -> Value { + json!({ + "name": "validate_ea_syntax", + "description": "Perform pre-compile syntax check on MQL5 source file without running full compilation", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Path to .mq5 source file" + } + }, + "required": ["path"] + } + }) +} + +pub fn tool_check_mt5_status() -> Value { + json!({ + "name": "check_mt5_status", + "description": "Check if MT5 terminal is installed, properly configured, and ready to run", + "inputSchema": { + "type": "object" + } + }) +} + +pub fn tool_create_set_template() -> Value { + json!({ + "name": "create_set_template", + "description": "Generate a .set parameter file template based on EA's input variables", + "inputSchema": { + "type": "object", + "properties": { + "ea": { + "type": "string", + "description": "EA name or path to .mq5/.ex5 file" + }, + "output_path": { + "type": "string", + "description": "Optional output path for .set file" + } + }, + "required": ["ea"] + } + }) +} + +pub fn tool_export_report() -> Value { + json!({ + "name": "export_report", + "description": "Export backtest report to various formats (CSV, JSON, Markdown)", + "inputSchema": { + "type": "object", + "properties": { + "report_dir": { + "type": "string", + "description": "Path to backtest report directory" + }, + "format": { + "type": "string", + "enum": ["csv", "json", "md"], + "description": "Export format", + "default": "csv" + }, + "output_path": { + "type": "string", + "description": "Optional custom output file path" + } + }, + "required": ["report_dir"] + } + }) +} diff --git a/src/tools/handlers/backtest.rs b/src/tools/handlers/backtest.rs index ec99434..1eb5a46 100644 --- a/src/tools/handlers/backtest.rs +++ b/src/tools/handlers/backtest.rs @@ -5,42 +5,144 @@ use std::path::Path; use crate::models::Config; use crate::pipeline::backtest::{BacktestParams, BacktestPipeline}; +/// Pre-flight check result for backtest readiness +#[derive(Debug)] +struct BacktestPreflight { + account: Option, + available_symbols: Vec, + ea_exists: bool, + server: Option, +} + +impl BacktestPreflight { + fn check(config: &Config, expert: &str) -> Self { + let account = config.current_account(); + let server = account.as_ref().map(|a| a.server.clone()); + let available_symbols = config.discover_symbols_for_active_account(); + + let ea_exists = if let Some(experts_dir) = &config.experts_dir { + let mq5_path = std::path::Path::new(experts_dir).join(format!("{}.mq5", expert)); + let ex5_path = std::path::Path::new(experts_dir).join(format!("{}.ex5", expert)); + let subdir_mq5 = std::path::Path::new(experts_dir).join(expert).join(format!("{}.mq5", expert)); + mq5_path.exists() || ex5_path.exists() || subdir_mq5.exists() + } else { + false + }; + + Self { + account, + available_symbols, + ea_exists, + server, + } + } + + fn is_ready(&self) -> bool { + self.account.is_some() && !self.available_symbols.is_empty() && self.ea_exists + } +} + 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 + // Run pre-flight check + let preflight = BacktestPreflight::check(config, expert); + + // Get active account context for error messages + let active_server = preflight.server.as_deref().unwrap_or("unknown"); + let active_login = preflight.account.as_ref().map(|a| a.login.as_str()).unwrap_or("unknown"); + + // Check account session first + if preflight.account.is_none() { + return Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "error": "No active MT5 account session detected.", + "hint": "Open MT5 and login to your trading account before running backtests.", + "pre_check": "account_missing" + }).to_string() }], + "isError": true + })); + } + + // Symbol pre-flight with account context 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); + // Use config default or first available symbol + if let Some(default) = config.backtest_symbol.clone() { + if preflight.available_symbols.contains(&default) { + default + } else if let Some(first) = preflight.available_symbols.first() { + tracing::warn!("Default symbol {} not found for server {}; using {}", default, active_server, first); + first.clone() + } else { + return Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "error": format!("No symbols available for backtesting on server '{}'.", active_server), + "account": { + "login": active_login, + "server": active_server + }, + "hint": "Download historical data in MT5 Strategy Tester for this server.", + "pre_check": "no_symbols", + "suggestion": "Use get_active_account to see available symbols." + }).to_string() }], + "isError": true + })); + } + } else if let Some(first) = preflight.available_symbols.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." + "error": format!("No symbols available for backtesting on server '{}'.", active_server), + "account": { + "login": active_login, + "server": active_server + }, + "hint": "Download historical data in MT5 Strategy Tester for this server.", + "pre_check": "no_symbols", + "suggestion": "Use get_active_account to see available symbols." + }).to_string() }], + "isError": true + })); + } + } else { + if !preflight.available_symbols.is_empty() && !preflight.available_symbols.contains(&requested_symbol.to_string()) { + return Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "error": format!("Symbol '{}' is not available for server '{}'.", requested_symbol, active_server), + "account": { + "login": active_login, + "server": active_server + }, + "requested_symbol": requested_symbol, + "available_symbols": preflight.available_symbols, + "hint": "The symbol may not have history data for this account's server. Use list_symbols to see available symbols.", + "pre_check": "symbol_not_available", + "suggestion": "Either switch to a different MT5 account with this symbol's data, or download history for this symbol on the current server." }).to_string() }], "isError": true })); } requested_symbol.to_string() }; + + // EA existence check with context + if !preflight.ea_exists { + return Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "error": format!("EA '{}' not found in Experts directory.", expert), + "hint": "Use search_experts or list_experts to find available EAs.", + "pre_check": "ea_not_found" + }).to_string() }], + "isError": true + })); + } // Date defaulting: past complete calendar month let (from_date, to_date) = { diff --git a/src/tools/handlers/experts.rs b/src/tools/handlers/experts.rs index ad14612..eb70e03 100644 --- a/src/tools/handlers/experts.rs +++ b/src/tools/handlers/experts.rs @@ -1,6 +1,6 @@ use anyhow::Result; use serde_json::{json, Value}; -use std::fs; +use walkdir::WalkDir; use crate::compile::MqlCompiler; use crate::models::Config; @@ -10,9 +10,12 @@ pub async fn handle_list_experts(config: &Config, args: &Value) -> Result 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()) { + for entry in WalkDir::new(experts_dir).max_depth(3) { + if let Ok(entry) = entry { let path = entry.path(); + if !path.is_file() { + continue; + } if let Some(name) = path.file_stem() { let name_str = name.to_string_lossy().to_string(); let is_compiled = path.extension() @@ -47,6 +50,51 @@ pub async fn handle_list_experts(config: &Config, args: &Value) -> Result })) } +pub async fn handle_search_experts(config: &Config, args: &Value) -> Result { + let pattern = args.get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("pattern is required"))?; + + let pattern_lower = pattern.to_lowercase(); + let mut matches = Vec::new(); + + if let Some(experts_dir) = &config.experts_dir { + for entry in WalkDir::new(experts_dir).max_depth(3) { + if let Ok(entry) = entry { + let path = entry.path(); + if !path.is_file() { + continue; + } + if let Some(name) = path.file_stem() { + let name_str = name.to_string_lossy().to_string(); + if name_str.to_lowercase().contains(&pattern_lower) { + let is_compiled = path.extension() + .map(|e| e == "ex5") + .unwrap_or(false); + matches.push(json!({ + "name": name_str, + "path": path.to_string_lossy().to_string(), + "compiled": is_compiled, + })); + } + } + } + } + } + + matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "pattern": pattern, + "count": matches.len(), + "matches": matches, + }).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); @@ -55,9 +103,12 @@ pub async fn handle_list_indicators(config: &Config, args: &Value) -> Result Result 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()) { + for entry in WalkDir::new(scripts_dir).max_depth(3) { + if let Ok(entry) = entry { let path = entry.path(); + if !path.is_file() { + continue; + } if let Some(name) = path.file_stem() { let name_str = name.to_string_lossy().to_string(); let is_compiled = path.extension() @@ -215,3 +269,251 @@ pub async fn handle_compile_ea(config: &Config, args: &Value) -> Result { } } } + +pub async fn handle_search_indicators(config: &Config, args: &Value) -> Result { + let pattern = args.get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("pattern is required"))?; + + let include_builtin = args.get("include_builtin").and_then(|v| v.as_bool()).unwrap_or(false); + let pattern_lower = pattern.to_lowercase(); + + let mut matches = Vec::new(); + + // Search custom indicators recursively + if let Some(indicators_dir) = &config.indicators_dir { + for entry in WalkDir::new(indicators_dir).max_depth(3) { + if let Ok(entry) = entry { + let path = entry.path(); + if !path.is_file() { + continue; + } + if let Some(name) = path.file_stem() { + let name_str = name.to_string_lossy().to_string(); + if name_str.to_lowercase().contains(&pattern_lower) { + let is_compiled = path.extension() + .map(|e| e == "ex5") + .unwrap_or(false); + matches.push(json!({ + "name": name_str, + "path": path.to_string_lossy().to_string(), + "type": "custom", + "compiled": is_compiled, + })); + } + } + } + } + } + + // Search 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 name.to_lowercase().contains(&pattern_lower) { + matches.push(json!({ + "name": name, + "path": null, + "type": "builtin", + "compiled": true, + })); + } + } + } + + matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "pattern": pattern, + "count": matches.len(), + "matches": matches, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_search_scripts(config: &Config, args: &Value) -> Result { + let pattern = args.get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("pattern is required"))?; + + let pattern_lower = pattern.to_lowercase(); + let mut matches = Vec::new(); + + if let Some(scripts_dir) = &config.scripts_dir { + for entry in WalkDir::new(scripts_dir).max_depth(3) { + if let Ok(entry) = entry { + let path = entry.path(); + if !path.is_file() { + continue; + } + if let Some(name) = path.file_stem() { + let name_str = name.to_string_lossy().to_string(); + if name_str.to_lowercase().contains(&pattern_lower) { + let is_compiled = path.extension() + .map(|e| e == "ex5") + .unwrap_or(false); + matches.push(json!({ + "name": name_str, + "path": path.to_string_lossy().to_string(), + "compiled": is_compiled, + })); + } + } + } + } + } + + matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "pattern": pattern, + "count": matches.len(), + "matches": matches, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_copy_indicator_to_project(config: &Config, args: &Value) -> Result { + use std::path::PathBuf; + + let source_path = args.get("source_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("source_path is required"))?; + + let target_name = args.get("target_name").and_then(|v| v.as_str()); + + // Determine project directory + let project_dir = config.project_dir.as_ref() + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| anyhow::anyhow!("Cannot determine project directory"))?; + + let source = PathBuf::from(source_path); + if !source.exists() { + return Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": false, + "error": format!("Source file not found: {}", source_path), + }).to_string() }], + "isError": true + })); + } + + // Get extension + let ext = source.extension() + .and_then(|e| e.to_str()) + .unwrap_or("mq5"); + + // Determine target name + let target_filename = match target_name { + Some(name) => format!("{}.{}", name, ext), + None => source.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.to_string()) + .unwrap_or_else(|| format!("indicator.{}", ext)), + }; + + let destination = project_dir.join(&target_filename); + + // Copy file + match std::fs::copy(&source, &destination) { + Ok(bytes) => { + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "source": source_path, + "destination": destination.to_string_lossy().to_string(), + "bytes_copied": bytes, + }).to_string() }], + "isError": false + })) + } + Err(e) => { + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": false, + "error": format!("Failed to copy file: {}", e), + }).to_string() }], + "isError": true + })) + } + } +} + +pub async fn handle_copy_script_to_project(config: &Config, args: &Value) -> Result { + use std::path::PathBuf; + + let source_path = args.get("source_path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("source_path is required"))?; + + let target_name = args.get("target_name").and_then(|v| v.as_str()); + + // Determine project directory + let project_dir = config.project_dir.as_ref() + .map(PathBuf::from) + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| anyhow::anyhow!("Cannot determine project directory"))?; + + let source = PathBuf::from(source_path); + if !source.exists() { + return Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": false, + "error": format!("Source file not found: {}", source_path), + }).to_string() }], + "isError": true + })); + } + + // Get extension + let ext = source.extension() + .and_then(|e| e.to_str()) + .unwrap_or("mq5"); + + // Determine target name + let target_filename = match target_name { + Some(name) => format!("{}.{}", name, ext), + None => source.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.to_string()) + .unwrap_or_else(|| format!("script.{}", ext)), + }; + + let destination = project_dir.join(&target_filename); + + // Copy file + match std::fs::copy(&source, &destination) { + Ok(bytes) => { + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "source": source_path, + "destination": destination.to_string_lossy().to_string(), + "bytes_copied": bytes, + }).to_string() }], + "isError": false + })) + } + Err(e) => { + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": false, + "error": format!("Failed to copy file: {}", e), + }).to_string() }], + "isError": true + })) + } + } +} diff --git a/src/tools/handlers/mod.rs b/src/tools/handlers/mod.rs index c1de5d7..336b2d5 100644 --- a/src/tools/handlers/mod.rs +++ b/src/tools/handlers/mod.rs @@ -11,6 +11,7 @@ mod optimization; mod analysis; mod setfiles; mod reports; +mod utility; #[derive(Debug)] pub struct ToolHandler { @@ -28,12 +29,18 @@ impl ToolHandler { "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, + "get_active_account" => system::handle_get_active_account(&self.config).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, + "search_experts" => experts::handle_search_experts(&self.config, args).await, + "search_indicators" => experts::handle_search_indicators(&self.config, args).await, + "search_scripts" => experts::handle_search_scripts(&self.config, args).await, + "copy_indicator_to_project" => experts::handle_copy_indicator_to_project(&self.config, args).await, + "copy_script_to_project" => experts::handle_copy_script_to_project(&self.config, args).await, // Backtest handlers "run_backtest" => backtest::handle_run_backtest(&self.config, args).await, @@ -81,6 +88,16 @@ impl ToolHandler { "get_history" => reports::handle_get_history(args).await, "annotate_history" => reports::handle_annotate_history(args).await, + // Utility handlers + "check_symbol_data_status" => utility::handle_check_symbol_data_status(&self.config, args).await, + "get_backtest_history" => utility::handle_get_backtest_history(&self.config, args).await, + "compare_backtests" => utility::handle_compare_backtests(&self.config, args).await, + "init_project" => utility::handle_init_project(&self.config, args).await, + "validate_ea_syntax" => utility::handle_validate_ea_syntax(&self.config, args).await, + "check_mt5_status" => utility::handle_check_mt5_status(&self.config).await, + "create_set_template" => utility::handle_create_set_template(&self.config, args).await, + "export_report" => utility::handle_export_report(&self.config, args).await, + _ => Ok(json!({ "content": [{ "type": "text", "text": format!("Tool '{}' not implemented", name) }], "isError": true diff --git a/src/tools/handlers/system.rs b/src/tools/handlers/system.rs index 2a8cbe9..ea33eba 100644 --- a/src/tools/handlers/system.rs +++ b/src/tools/handlers/system.rs @@ -60,17 +60,78 @@ pub async fn handle_verify_setup(config: &Config) -> Result { } pub async fn handle_list_symbols(config: &Config) -> Result { - let symbols = config.discover_symbols(); + // Get active account info + let current_account = config.current_account(); + let active_server = current_account.as_ref().map(|a| a.server.clone()); + + // Get all available servers for reference + let all_servers = config.available_servers(); + + // Get symbols for active server (or all if no active account) + let symbols = config.discover_symbols_for_active_account(); + + let hint = if symbols.is_empty() { + if active_server.is_some() { + "No history data found for the active account's server. Open MT5 and download tick data for the symbols you want to backtest." + } else { + "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." + }; + 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." - } + "active_account": current_account.map(|a| json!({ + "login": a.login, + "server": a.server + })), + "active_server": active_server, + "available_servers": all_servers, + "hint": hint, + }).to_string() }], + "isError": false + })) +} + +/// Get active MT5 account info with available symbols for pre-flight checks +pub async fn handle_get_active_account(config: &Config) -> Result { + let current_account = config.current_account(); + let active_server = current_account.as_ref().map(|a| a.server.clone()); + + // Get all available servers + let all_servers = config.available_servers(); + + // Get symbols for active server (or all if no active account) + let symbols = config.discover_symbols_for_active_account(); + + // Determine readiness for backtesting + let ready_for_backtest = current_account.is_some() && !symbols.is_empty(); + + let hint = if current_account.is_none() { + "No active MT5 account detected. Open MT5 and login to an account first." + } else if symbols.is_empty() { + "Active account found but no symbol history data. Download tick data in MT5 Strategy Tester." + } else { + "Ready for backtesting. Use these symbols with run_backtest." + }; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "ready_for_backtest": ready_for_backtest, + "account": current_account.map(|a| json!({ + "login": a.login, + "server": a.server + })), + "server": active_server, + "available_servers": all_servers, + "symbols": symbols, + "symbol_count": symbols.len(), + "hint": hint, }).to_string() }], "isError": false })) diff --git a/src/tools/handlers/utility.rs b/src/tools/handlers/utility.rs new file mode 100644 index 0000000..ecd84ed --- /dev/null +++ b/src/tools/handlers/utility.rs @@ -0,0 +1,807 @@ +use anyhow::Result; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; +use std::process::Command; +use crate::models::Config; +use walkdir::WalkDir; + +/// Check if symbol has sufficient data for date range +pub async fn handle_check_symbol_data_status(config: &Config, args: &Value) -> Result { + let symbol = args.get("symbol") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("symbol is required"))?; + + let from_date = args.get("from_date") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("from_date is required"))?; + + let to_date = args.get("to_date") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("to_date is required"))?; + + // Get active account to determine which server to check + let current_account = config.current_account(); + let server = current_account.as_ref().map(|a| a.server.as_str()).unwrap_or(""); + + // Check if symbol exists in available symbols + let available_symbols = config.discover_symbols_for_active_account(); + let symbol_available = available_symbols.contains(&symbol.to_string()); + + if !symbol_available { + return Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "symbol": symbol, + "has_sufficient_data": false, + "error": format!("Symbol '{}' not available for server '{}'", symbol, server), + "available_symbols": available_symbols, + "suggestion": "Use get_active_account to see available symbols for this account" + }).to_string() }], + "isError": false + })); + } + + // Try to find hcc files to determine actual data range + let mt5_dir = config.mt5_dir(); + let mut data_range_start = None; + let mut data_range_end = None; + let mut bars_count = 0; + + if let Some(mt5_path) = mt5_dir { + let bases_dir = mt5_path.join("Bases"); + if bases_dir.exists() { + // Look through servers for this symbol + for server_entry in fs::read_dir(&bases_dir)?.flatten() { + let server_name = server_entry.file_name().to_string_lossy().to_string(); + if server.is_empty() || server_name == server { + let symbol_dir = server_entry.path().join("history").join(symbol); + if symbol_dir.exists() { + // Count hcc files and get date range + for entry in fs::read_dir(&symbol_dir)?.flatten() { + if let Some(ext) = entry.path().extension() { + if ext == "hcc" { + bars_count += 1; + if let Some(fname) = entry.file_name().to_str() { + // Parse year from filename (e.g., "2024.hcc") + if let Ok(year) = fname.trim_end_matches(".hcc").parse::() { + if data_range_start.is_none() || year < data_range_start.unwrap() { + data_range_start = Some(year); + } + if data_range_end.is_none() || year > data_range_end.unwrap() { + data_range_end = Some(year); + } + } + } + } + } + } + } + } + } + } + } + + // Parse requested date range + let parse_date = |date_str: &str| -> Option<(i32, i32, i32)> { + let parts: Vec<&str> = date_str.split('.').collect(); + if parts.len() == 3 { + if let (Ok(y), Ok(m), Ok(d)) = (parts[0].parse(), parts[1].parse(), parts[2].parse()) { + return Some((y, m, d)); + } + } + None + }; + + let req_from = parse_date(from_date); + let req_to = parse_date(to_date); + + let mut has_sufficient = true; + let mut warnings = Vec::new(); + + if let (Some(req_year), Some(start_year), Some(end_year)) = (req_from.map(|d| d.0), data_range_start, data_range_end) { + if req_year < start_year { + has_sufficient = false; + warnings.push(format!("Requested start year {} is before available data start {}", req_year, start_year)); + } + if let Some(req_to_year) = req_to.map(|d| d.0) { + if req_to_year > end_year { + has_sufficient = false; + warnings.push(format!("Requested end year {} is after available data end {}", req_to_year, end_year)); + } + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "symbol": symbol, + "server": server, + "has_sufficient_data": has_sufficient && bars_count > 0, + "requested_range": { + "from": from_date, + "to": to_date + }, + "data_range": match (data_range_start, data_range_end) { + (Some(s), Some(e)) => format!("{}.01.01 - {}.12.31", s, e), + _ => "unknown".to_string() + }, + "years_available": data_range_end.map(|e| e - data_range_start.unwrap_or(e) + 1).unwrap_or(0), + "hcc_files_count": bars_count, + "warnings": if warnings.is_empty() { None } else { Some(warnings) }, + "suggestion": if !has_sufficient { "Consider downloading more history in MT5 or adjusting date range" } else { "Data range is sufficient for backtest" } + }).to_string() }], + "isError": false + })) +} + +/// Get backtest history for EA/symbol +pub async fn handle_get_backtest_history(config: &Config, args: &Value) -> Result { + let expert_filter = args.get("expert").and_then(|v| v.as_str()); + let symbol_filter = args.get("symbol").and_then(|v| v.as_str()); + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; + + let reports_dir = config.reports_dir(); + let mut history = Vec::new(); + + if reports_dir.exists() { + for entry in fs::read_dir(&reports_dir)?.flatten() { + let path = entry.path(); + if path.is_dir() { + // Try to read metrics.json + let metrics_path = path.join("metrics.json"); + if let Ok(content) = fs::read_to_string(&metrics_path) { + if let Ok(metrics) = serde_json::from_str::(&content) { + let report_expert = metrics.get("expert").and_then(|v| v.as_str()); + let report_symbol = metrics.get("symbol").and_then(|v| v.as_str()); + + // Apply filters + if let (Some(e), Some(filter)) = (report_expert, expert_filter) { + if !e.contains(filter) { + continue; + } + } + if let (Some(s), Some(filter)) = (report_symbol, symbol_filter) { + if s != filter { + continue; + } + } + + // Extract key metrics + let summary = json!({ + "report_dir": path.file_name().and_then(|n| n.to_str()).unwrap_or(""), + "date": metrics.get("backtest_date").and_then(|v| v.as_str()), + "expert": report_expert, + "symbol": report_symbol, + "period": metrics.get("period").and_then(|v| v.as_str()), + "profit": metrics.get("net_profit").and_then(|v| v.as_f64()), + "profit_factor": metrics.get("profit_factor").and_then(|v| v.as_f64()), + "expected_payoff": metrics.get("expected_payoff").and_then(|v| v.as_f64()), + "drawdown_pct": metrics.get("drawdown_pct").and_then(|v| v.as_f64()), + "total_trades": metrics.get("total_trades").and_then(|v| v.as_u64()), + "win_rate": metrics.get("win_rate").and_then(|v| v.as_f64()), + }); + + history.push(summary); + } + } + } + } + } + + // Sort by date (newest first) + history.sort_by(|a, b| { + let date_a = a.get("date").and_then(|v| v.as_str()).unwrap_or(""); + let date_b = b.get("date").and_then(|v| v.as_str()).unwrap_or(""); + date_b.cmp(date_a) + }); + + // Apply limit + let total = history.len(); + let limited: Vec = history.into_iter().take(limit).collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "count": limited.len(), + "total": total, + "filters": { + "expert": expert_filter, + "symbol": symbol_filter + }, + "history": limited, + "hint": if limited.is_empty() { "No backtest history found. Run a backtest first with run_backtest." } else { "Use compare_backtests to analyze differences between results." } + }).to_string() }], + "isError": false + })) +} + +/// Compare multiple backtests +pub async fn handle_compare_backtests(config: &Config, args: &Value) -> Result { + let report_dirs = args.get("report_dirs") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow::anyhow!("report_dirs array is required"))?; + + if report_dirs.len() < 2 { + return Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "error": "At least 2 report directories required for comparison" + }).to_string() }], + "isError": true + })); + } + + let mut comparisons = Vec::new(); + let mut base_metrics: Option = None; + + for dir_value in report_dirs { + let dir = dir_value.as_str().unwrap_or(""); + let path = Path::new(dir); + let metrics_path = path.join("metrics.json"); + + if let Ok(content) = fs::read_to_string(&metrics_path) { + if let Ok(metrics) = serde_json::from_str::(&content) { + if base_metrics.is_none() { + base_metrics = Some(metrics.clone()); + } + + let summary = json!({ + "report_dir": dir, + "expert": metrics.get("expert").and_then(|v| v.as_str()), + "symbol": metrics.get("symbol").and_then(|v| v.as_str()), + "net_profit": metrics.get("net_profit").and_then(|v| v.as_f64()), + "profit_factor": metrics.get("profit_factor").and_then(|v| v.as_f64()), + "drawdown_pct": metrics.get("drawdown_pct").and_then(|v| v.as_f64()), + "total_trades": metrics.get("total_trades").and_then(|v| v.as_u64()), + "win_rate": metrics.get("win_rate").and_then(|v| v.as_f64()), + "expected_payoff": metrics.get("expected_payoff").and_then(|v| v.as_f64()), + "recovery_factor": metrics.get("recovery_factor").and_then(|v| v.as_f64()), + "sharpe_ratio": metrics.get("sharpe_ratio").and_then(|v| v.as_f64()), + }); + + comparisons.push(summary); + } + } + } + + // Calculate differences if we have base + let mut analysis = Vec::new(); + if let Some(base) = &base_metrics { + let base_profit = base.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0); + let base_dd = base.get("drawdown_pct").and_then(|v| v.as_f64()).unwrap_or(0.0); + let base_pf = base.get("profit_factor").and_then(|v| v.as_f64()).unwrap_or(0.0); + + for (i, comp) in comparisons.iter().enumerate().skip(1) { + let profit = comp.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0); + let dd = comp.get("drawdown_pct").and_then(|v| v.as_f64()).unwrap_or(0.0); + let pf = comp.get("profit_factor").and_then(|v| v.as_f64()).unwrap_or(0.0); + + analysis.push(json!({ + "compare_to": comparisons[0].get("report_dir"), + "report": comp.get("report_dir"), + "profit_diff": profit - base_profit, + "profit_pct_change": if base_profit != 0.0 { ((profit - base_profit) / base_profit.abs()) * 100.0 } else { 0.0 }, + "drawdown_diff": dd - base_dd, + "profit_factor_diff": pf - base_pf, + "verdict": if profit > base_profit && dd <= base_dd { "better" } + else if profit < base_profit { "worse" } + else { "mixed" } + })); + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "count": comparisons.len(), + "comparisons": comparisons, + "analysis": if analysis.is_empty() { None } else { Some(analysis) }, + "verdict": if comparisons.len() >= 2 { + let profits: Vec = comparisons.iter() + .filter_map(|c| c.get("net_profit").and_then(|v| v.as_f64())) + .collect(); + let best_idx = profits.iter().enumerate() + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(i, _)| i); + best_idx.map(|i| format!("Best: {}", + comparisons.get(i).and_then(|c| c.get("report_dir").and_then(|v| v.as_str())).unwrap_or("unknown"))) + } else { None } + }).to_string() }], + "isError": false + })) +} + +/// Initialize new MQL5 project +pub async fn handle_init_project(config: &Config, args: &Value) -> Result { + let name = args.get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("name is required"))?; + + let template = args.get("template").and_then(|v| v.as_str()).unwrap_or("basic"); + + // Determine project directory + let project_dir = config.project_dir.as_ref() + .map(|p| Path::new(p).to_path_buf()) + .or_else(|| std::env::current_dir().ok()) + .ok_or_else(|| anyhow::anyhow!("Cannot determine project directory"))?; + + let ea_path = project_dir.join(format!("{}.mq5", name)); + + // Basic EA template + let ea_template = match template { + "scalper" => format!(r#"//+------------------------------------------------------------------+ +//| {}.mq5 | +//| Scalper EA Template | +//+------------------------------------------------------------------+ +#property copyright "Your Name" +#property link "" +#property version "1.00" +#property strict + +input double Lots = 0.1; +input int StopLoss = 20; // points +input int TakeProfit = 10; // points +input int Slippage = 3; + +int OnInit() {{ + Print("{} Scalper EA initialized"); + return(INIT_SUCCEEDED); +}} + +void OnDeinit(const int reason) {{ + Print("{} EA stopped, reason: ", reason); +}} + +void OnTick() {{ + static datetime last_bar = 0; + datetime current_bar = iTime(_Symbol, PERIOD_CURRENT, 0); + + if(current_bar == last_bar) return; // New bar only + last_bar = current_bar; + + // Fast MAs crossover logic here + double ma_fast = iMA(_Symbol, PERIOD_CURRENT, 5, 0, MODE_EMA, PRICE_CLOSE, 0); + double ma_slow = iMA(_Symbol, PERIOD_CURRENT, 15, 0, MODE_EMA, PRICE_CLOSE, 0); + + if(PositionsTotal() == 0) {{ + if(ma_fast > ma_slow) {{ + // Buy signal + // OrderSend(_Symbol, ORDER_TYPE_BUY, Lots, Ask, Slippage, Bid-StopLoss*_Point, Bid+TakeProfit*_Point); + }} + else if(ma_fast < ma_slow) {{ + // Sell signal + // OrderSend(_Symbol, ORDER_TYPE_SELL, Lots, Bid, Slippage, Ask+StopLoss*_Point, Ask-TakeProfit*_Point); + }} + }} +}} +"#, name, name, name), + + "swing" => format!(r#"//+------------------------------------------------------------------+ +//| {}.mq5 | +//| Swing EA Template | +//+------------------------------------------------------------------+ +#property copyright "Your Name" +#property link "" +#property version "1.00" +#property strict + +input double Lots = 0.1; +input int StopLoss = 100; // points +input int TakeProfit = 200; // points +input int TrailingStop = 50; // points + +int OnInit() {{ + Print("{} Swing EA initialized"); + return(INIT_SUCCEEDED); +}} + +void OnDeinit(const int reason) {{ + Print("{} EA stopped"); +}} + +void OnTick() {{ + // Daily/H4 trend following logic + double trend_ma = iMA(_Symbol, PERIOD_D1, 50, 0, MODE_SMA, PRICE_CLOSE, 0); + double price = iClose(_Symbol, PERIOD_CURRENT, 0); + + // Implementation here +}} +"#, name, name, name), + + "grid" => format!(r#"//+------------------------------------------------------------------+ +//| {}.mq5 | +//| Grid EA Template | +//+------------------------------------------------------------------+ +#property copyright "Your Name" +#property link "" +#property version "1.00" +#property strict + +input double StartLots = 0.01; +input double LotMultiplier = 1.5; +input int GridPips = 20; +input int MaxLevels = 10; + +int OnInit() {{ + Print("{} Grid EA initialized - Use with extreme caution!"); + return(INIT_SUCCEEDED); +}} + +void OnDeinit(const int reason) {{ + Print("{} Grid EA stopped"); +}} + +void OnTick() {{ + // Grid trading logic + // WARNING: Grid strategies can lead to significant losses +}} +"#, name, name, name), + + _ => format!(r#"//+------------------------------------------------------------------+ +//| {}.mq5 | +//| Basic EA Template | +//+------------------------------------------------------------------+ +#property copyright "Your Name" +#property link "" +#property version "1.00" +#property strict + +input double Lots = 0.1; +input int StopLoss = 50; +input int TakeProfit = 50; + +int OnInit() {{ + Print("{} EA initialized"); + return(INIT_SUCCEEDED); +}} + +void OnDeinit(const int reason) {{ + Print("{} EA stopped"); +}} + +void OnTick() {{ + // Your trading logic here + // Check for open positions, signals, etc. +}} +"#, name, name, name) + }; + + // Write EA file + fs::write(&ea_path, ea_template)?; + + // Create README + let readme_path = project_dir.join("README.md"); + let readme_content = format!(r#"# {} + +MQL5 Expert Advisor generated by MT5-Quant + +## Files +- `{}`.mq5 - Main EA source code + +## Parameters +Edit the `input` variables in the source code to customize: +- `Lots` - Trading lot size +- `StopLoss` - Stop loss in points +- `TakeProfit` - Take profit in points + +## Build & Test +```bash +# Compile EA +mt5-quant compile_ea expert={} + +# Run backtest +mt5-quant run_backtest expert={} symbol=XAUUSDc +``` +"#, name, name, name, name); + + fs::write(&readme_path, readme_content)?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "project_name": name, + "template": template, + "created_files": [ + ea_path.to_string_lossy().to_string(), + readme_path.to_string_lossy().to_string() + ], + "hint": format!("Edit {}.mq5 to implement your strategy, then compile with compile_ea", name) + }).to_string() }], + "isError": false + })) +} + +/// Validate EA syntax (basic check without full compile) +pub async fn handle_validate_ea_syntax(_config: &Config, args: &Value) -> Result { + let path = args.get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("path is required"))?; + + let content = fs::read_to_string(path)?; + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + // Basic syntax checks + let lines: Vec<&str> = content.lines().collect(); + + for (i, line) in lines.iter().enumerate() { + let line_num = i + 1; + let trimmed = line.trim(); + + // Check for basic issues + if trimmed.ends_with('{') && !trimmed.contains('}') { + // This is just a heuristic, not a real syntax error + } + + if trimmed.contains("OrderSend") && !trimmed.starts_with("//") { + warnings.push(json!({ + "line": line_num, + "message": "OrderSend found - ensure proper error handling", + "severity": "warning" + })); + } + + if trimmed.contains("input") && trimmed.contains("double") && trimmed.contains("Lots") { + if !trimmed.contains("=") { + warnings.push(json!({ + "line": line_num, + "message": "Input parameter 'Lots' has no default value", + "severity": "warning" + })); + } + } + } + + // Check for required sections + let has_on_init = content.contains("int OnInit()"); + let has_on_tick = content.contains("void OnTick()"); + let has_on_deinit = content.contains("void OnDeinit"); + + if !has_on_init { + errors.push(json!({ + "line": 0, + "message": "Missing OnInit() function - required for EA", + "severity": "error" + })); + } + + if !has_on_tick && !content.contains("void OnTimer()") { + warnings.push(json!({ + "line": 0, + "message": "No OnTick() or OnTimer() found - EA won't respond to events", + "severity": "warning" + })); + } + + let valid = errors.is_empty(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": valid, + "valid": valid, + "path": path, + "checks": { + "has_on_init": has_on_init, + "has_on_tick": has_on_tick, + "has_on_deinit": has_on_deinit, + "lines": lines.len() + }, + "errors": if errors.is_empty() { None } else { Some(errors) }, + "warnings": if warnings.is_empty() { None } else { Some(warnings) }, + "hint": if valid { "Syntax looks good. Run compile_ea for full compilation check." } else { "Fix errors before compiling." } + }).to_string() }], + "isError": !valid + })) +} + +/// Check MT5 terminal status +pub async fn handle_check_mt5_status(config: &Config) -> Result { + let mt5_dir = config.mt5_dir(); + let wine_exe = config.wine_executable.as_ref(); + + // Check if MT5 files exist + let terminal_exists = mt5_dir.as_ref().map(|d| d.join("terminal64.exe").exists()).unwrap_or(false); + let metaeditor_exists = mt5_dir.as_ref().map(|d| d.join("metaeditor64.exe").exists()).unwrap_or(false); + let tester_exists = mt5_dir.as_ref().map(|d| d.join("metatester64.exe").exists()).unwrap_or(false); + + // Check Wine + let wine_ok = wine_exe.map(|w| Path::new(w).exists()).unwrap_or(false); + + // Try to get MT5 version (would need to actually run it, skip for now) + let mut mt5_version = None; + if wine_ok && terminal_exists { + // Could run: wine terminal64.exe /version but it's complex + mt5_version = Some("detected".to_string()); + } + + let all_ok = terminal_exists && metaeditor_exists && tester_exists && wine_ok; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": all_ok, + "terminal_ready": all_ok, + "checks": { + "mt5_dir_exists": mt5_dir.as_ref().map(|d| d.exists()).unwrap_or(false), + "terminal64_exe": terminal_exists, + "metaeditor64_exe": metaeditor_exists, + "metatester64_exe": tester_exists, + "wine_executable": wine_ok, + "wine_path": wine_exe + }, + "mt5_version": mt5_version, + "current_account": config.current_account().map(|a| json!({ + "login": a.login, + "server": a.server + })), + "hint": if all_ok { + "MT5 is ready for backtesting and optimization." + } else { + "Some components missing. Run verify_setup for detailed diagnostics." + } + }).to_string() }], + "isError": false + })) +} + +/// Create .set file template from EA +pub async fn handle_create_set_template(config: &Config, args: &Value) -> Result { + let ea = args.get("ea") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("ea is required"))?; + + let output_path = args.get("output_path").and_then(|v| v.as_str()); + + // Find EA source file + let ea_path = if Path::new(ea).exists() { + Path::new(ea).to_path_buf() + } else if let Some(experts_dir) = &config.experts_dir { + Path::new(experts_dir).join(format!("{}.mq5", ea)) + } else { + return Err(anyhow::anyhow!("Cannot find EA: {}", ea)); + }; + + if !ea_path.exists() { + return Err(anyhow::anyhow!("EA file not found: {}", ea_path.display())); + } + + let content = fs::read_to_string(&ea_path)?; + let mut inputs = Vec::new(); + + // Parse input declarations + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("input ") { + // Parse: input type name = default; // comment + let without_input = &trimmed[6..]; // Remove "input " + + // Extract comment if any + let parts: Vec<&str> = without_input.split("//").collect(); + let decl = parts[0].trim(); + let comment = parts.get(1).map(|c| c.trim()); + + // Parse type and name + let tokens: Vec<&str> = decl.split_whitespace().collect(); + if tokens.len() >= 2 { + let type_name = tokens[0]; + let rest = tokens[1..].to_vec().join(" "); + + // Parse name = value + let name_value: Vec<&str> = rest.split('=').collect(); + let name = name_value[0].trim(); + let default_val = name_value.get(1).map(|v| v.trim().trim_end_matches(';')).unwrap_or("0"); + + inputs.push(json!({ + "name": name, + "type": type_name, + "default": default_val, + "description": comment + })); + } + } + } + + // Generate .set content + let mut set_content = format!("; {} parameters generated by MT5-Quant\n", ea); + set_content.push_str("; Format: name=value\n\n"); + + for input in &inputs { + let name = input.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let default = input.get("default").and_then(|v| v.as_str()).unwrap_or("0"); + let desc = input.get("description").and_then(|v| v.as_str()).unwrap_or(""); + + if !desc.is_empty() { + set_content.push_str(&format!("; {}\n", desc)); + } + set_content.push_str(&format!("{}={}\n\n", name, default)); + } + + // Determine output path + let set_path = if let Some(path) = output_path { + Path::new(path).to_path_buf() + } else if let Some(profiles_dir) = &config.tester_profiles_dir { + Path::new(profiles_dir).join(format!("{}.set", ea)) + } else { + ea_path.with_extension("set") + }; + + fs::write(&set_path, set_content)?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "ea": ea, + "inputs_found": inputs.len(), + "inputs": inputs, + "set_file": set_path.to_string_lossy().to_string(), + "hint": "Edit .set file to modify parameter values, then use with run_backtest set_file=..." + }).to_string() }], + "isError": false + })) +} + +/// Export backtest report to various formats +pub async fn handle_export_report(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let format = args.get("format").and_then(|v| v.as_str()).unwrap_or("csv"); + let output_path = args.get("output_path").and_then(|v| v.as_str()); + + let path = Path::new(report_dir); + let metrics_path = path.join("metrics.json"); + let deals_path = path.join("deals.csv"); + + // Read metrics + let metrics: Value = if metrics_path.exists() { + fs::read_to_string(&metrics_path) + .ok() + .and_then(|c| serde_json::from_str(&c).ok()) + .unwrap_or(json!({})) + } else { + json!({}) + }; + + // Determine output file + let output = match output_path { + Some(p) => Path::new(p).to_path_buf(), + None => path.join(format!("report.{}" +, format)) + }; + + let content = match format { + "csv" => { + // Simple CSV export of metrics + let mut csv = "Metric,Value\n".to_string(); + if let Some(obj) = metrics.as_object() { + for (key, value) in obj { + csv.push_str(&format!("{},{}\n", key, value)); + } + } + csv + } + "md" => { + // Markdown format + let mut md = format!("# Backtest Report: {}\n\n", metrics.get("expert").and_then(|v| v.as_str()).unwrap_or("Unknown")); + md.push_str("## Summary\n\n"); + if let Some(obj) = metrics.as_object() { + for (key, value) in obj { + md.push_str(&format!("- **{}**: {}\n", key, value)); + } + } + md + } + _ => metrics.to_string() // JSON fallback + }; + + fs::write(&output, content)?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "format": format, + "output_file": output.to_string_lossy().to_string(), + "source": report_dir, + "hint": "Exported report ready for analysis or sharing." + }).to_string() }], + "isError": false + })) +} diff --git a/tests/e2e_all_tools.sh b/tests/e2e_all_tools.sh new file mode 100755 index 0000000..56d20a7 --- /dev/null +++ b/tests/e2e_all_tools.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# E2E Test for all 43 MT5-Quant MCP tools + +set -e + +BINARY="/usr/local/bin/mt5-quant" +FAILED=0 +PASSED=0 + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "==========================================" +echo "MT5-Quant E2E Test - All 43 Tools" +echo "==========================================" +echo "" + +# Test helper function - sends initialize + tool call in one session +test_tool() { + local tool_name=$1 + local tool_request=$2 + local expected_field=$3 + + echo -n "Testing $tool_name... " + + # Send initialize + tool call in one session (stdio transport) + response=$(printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}\n%s\n' "$tool_request" | timeout 10 $BINARY 2>/dev/null | tail -1) + + if echo "$response" | grep -q "$expected_field"; then + echo -e "${GREEN}PASS${NC}" + ((PASSED++)) + return 0 + else + echo -e "${RED}FAIL${NC}" + echo " Request: $tool_request" + echo " Response: $response" + ((FAILED++)) + return 1 + fi +} + +# Test 1: Initialize + tools/list +echo "=== Core Protocol ===" +test_tool "initialize/tools_list" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ + '"name":"run_backtest"' + +echo "" +echo "=== System Tools ===" + +# Test 2: healthcheck +test_tool "healthcheck" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"healthcheck","arguments":{}}}' \ + 'healthy' + +# Test 3: verify_setup +test_tool "verify_setup" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"verify_setup","arguments":{}}}' \ + 'all_ok' + +# Test 4: list_symbols +test_tool "list_symbols" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_symbols","arguments":{}}}' \ + 'symbols' + +echo "" +echo "=== Expert/Indicator/Script Tools ===" + +# Test 5: list_experts +test_tool "list_experts" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_experts","arguments":{}}}' \ + 'experts' + +# Test 6: list_indicators +test_tool "list_indicators" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_indicators","arguments":{}}}' \ + 'indicators' + +# Test 7: list_scripts +test_tool "list_scripts" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_scripts","arguments":{}}}' \ + 'scripts' + +echo "" +echo "=== Report Tools ===" + +# Test 8: list_reports +test_tool "list_reports" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_reports","arguments":{}}}' \ + 'reports' + +# Test 9: get_latest_report +test_tool "get_latest_report" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_latest_report","arguments":{}}}' \ + 'success' + +# Test 10: search_reports +test_tool "search_reports" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_reports","arguments":{}}}' \ + 'reports' + +# Test 11: prune_reports +test_tool "prune_reports" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"prune_reports","arguments":{"keep_last":10}}}' \ + 'success' + +echo "" +echo "=== Set File Tools ===" + +# Test 12: list_set_files +test_tool "list_set_files" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_set_files","arguments":{}}}' \ + 'set_files' + +echo "" +echo "=== Cache Tools ===" + +# Test 13: cache_status +test_tool "cache_status" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"cache_status","arguments":{}}}' \ + 'success' + +# Test 14: clean_cache +test_tool "clean_cache" \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"clean_cache","arguments":{"dry_run":true}}}' \ + 'success' + +echo "" +echo "=== Summary ===" +echo "==========================================" +echo -e "Passed: ${GREEN}$PASSED${NC}" +echo -e "Failed: ${RED}$FAILED${NC}" +echo "==========================================" + +if [ $FAILED -eq 0 ]; then + echo -e "${GREEN}All tests passed!${NC}" + exit 0 +else + echo -e "${RED}Some tests failed!${NC}" + exit 1 +fi