Refactor handlers into SRP modules and add indicator/script tools
**Handler Refactoring:** - Split monolithic handlers.rs (1468 lines) into 7 focused modules: - system.rs: verify_setup, healthcheck, list_symbols - experts.rs: list_experts, list_indicators, list_scripts, compile_ea - backtest.rs: run_backtest, get_backtest_status, cache_status, clean_cache - optimization.rs: run_optimization, get_optimization_results, list_jobs - analysis.rs: analyze_report, compare_baseline - setfiles.rs: read/write/patch/clone/diff set files - reports.rs: list/search/prune reports, archive, promote, annotate **New Tools:** - list_indicators: List custom indicators in MQL5/Indicators - list_scripts: List scripts in MQL5/Scripts **Config Updates:** - Added indicators_dir and scripts_dir to Config model - Auto-discovery for MQL5/Indicators and MQL5/Scripts paths - Updated healthcheck to validate new directories **Benefits:** - Single Responsibility Principle: each module handles one domain - Easier maintenance and testing - Clear separation of concerns - Reduced cognitive load when modifying handlers
This commit is contained in:
+328
-70
@@ -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<String>,
|
||||
pub terminal_dir: Option<String>,
|
||||
pub experts_dir: Option<String>,
|
||||
pub indicators_dir: Option<String>,
|
||||
pub scripts_dir: Option<String>,
|
||||
pub tester_profiles_dir: Option<String>,
|
||||
pub tester_cache_dir: Option<String>,
|
||||
pub display_mode: Option<String>,
|
||||
@@ -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<Self> {
|
||||
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<String, String> = 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<String> {
|
||||
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<PathBuf> {
|
||||
let mut candidates: Vec<PathBuf> = 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<String>| v.clone().unwrap_or_else(none);
|
||||
let u = |v: Option<u32>| 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<Self> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut map: HashMap<String, String> = 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<std::path::PathBuf> {
|
||||
/// 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<PathBuf> {
|
||||
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<String> {
|
||||
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<String> = symbols.into_iter().collect();
|
||||
sorted.sort();
|
||||
sorted
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<Value> {
|
||||
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<Vec<Deal>> {
|
||||
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<Value> {
|
||||
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;
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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
|
||||
}))
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let runner = OptimizationRunner::new(config.clone());
|
||||
let jobs = runner.list_jobs()?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({ "jobs": jobs }).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<Value> = 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<Value> {
|
||||
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<Value> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> = 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<Value> {
|
||||
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<Vec<String>> = 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
|
||||
}))
|
||||
}
|
||||
@@ -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<Value> {
|
||||
let path = args.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("path is required"))?;
|
||||
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut 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<Value> {
|
||||
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<Value> {
|
||||
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<String> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let path = args.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("path is required"))?;
|
||||
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut 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<Value> {
|
||||
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
|
||||
}))
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<String>, 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<Value> {
|
||||
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<String>,
|
||||
mt5_dir_found: bool,
|
||||
mt5_dir: Option<String>,
|
||||
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<Value> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user