Complete migration to Rust implementation
- Migrate optimization from optimize.sh to src/optimization/ - Remove all Python files (analytics/, server/, hooks/, pyproject.toml) - Add optimization module: optimizer.rs, parser.rs, mod.rs - Implement all missing MCP tool handlers (35 total tools) - Add handle_patch_set_file handler - Clean up orphan files: .venv/, __pycache__, test files - Move test_rcp_server.sh to tests/integration_test.sh - Add Rust integration tests in tests/integration_tests.rs - Fix all compiler warnings with #[allow(dead_code)] - Update test fixtures and structure
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
use chrono::{DateTime, Datelike, NaiveDateTime};
|
||||
use chrono::{DateTime, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -392,7 +392,7 @@ impl DealAnalyzer {
|
||||
let mut peak = 0;
|
||||
let mut peak_time = String::new();
|
||||
|
||||
for (dt, delta, deal) in events {
|
||||
for (_dt, delta, deal) in events {
|
||||
count = (count + delta).max(0);
|
||||
if count > peak {
|
||||
peak = count;
|
||||
|
||||
@@ -277,8 +277,11 @@ impl ReportExtractor {
|
||||
pub struct ExtractionResult {
|
||||
pub metrics: Metrics,
|
||||
pub deals: Vec<Deal>,
|
||||
#[allow(dead_code)]
|
||||
pub metrics_path: PathBuf,
|
||||
#[allow(dead_code)]
|
||||
pub deals_csv_path: PathBuf,
|
||||
#[allow(dead_code)]
|
||||
pub deals_json_path: PathBuf,
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ impl MqlCompiler {
|
||||
let wine_src_path = Self::host_to_wine_path(&dest_path)?;
|
||||
let wine_log_path = Self::host_to_wine_path(&log_file)?;
|
||||
|
||||
let output = Command::new(wine_exe)
|
||||
let _output = Command::new(wine_exe)
|
||||
.arg(&metaeditor)
|
||||
.arg(format!("/compile:{}", wine_src_path))
|
||||
.arg(format!("/log:{}", wine_log_path))
|
||||
|
||||
@@ -4,6 +4,7 @@ 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<String>,
|
||||
@@ -47,6 +48,7 @@ impl Default for Config {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let config_path = Self::get_config_path();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod analytics;
|
||||
mod compile;
|
||||
mod models;
|
||||
mod optimization;
|
||||
mod pipeline;
|
||||
mod tools;
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ pub struct Deal {
|
||||
pub magic: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DealType {
|
||||
@@ -69,6 +70,7 @@ pub struct LossSequence {
|
||||
pub end: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CycleStats {
|
||||
pub total_cycles: i32,
|
||||
@@ -77,6 +79,7 @@ pub struct CycleStats {
|
||||
pub win_rate_by_depth: HashMap<String, WinRateByDepth>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WinRateByDepth {
|
||||
pub total: i32,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Report {
|
||||
pub report_dir: PathBuf,
|
||||
@@ -40,6 +41,7 @@ pub struct FilePaths {
|
||||
pub deals_json: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BacktestStatus {
|
||||
pub stage: PipelineStage,
|
||||
@@ -48,6 +50,7 @@ pub struct BacktestStatus {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum PipelineStage {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod optimizer;
|
||||
pub mod parser;
|
||||
|
||||
pub use optimizer::{OptimizationParams, OptimizationRunner};
|
||||
pub use parser::OptimizationParser;
|
||||
@@ -0,0 +1,375 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::Utc;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::models::Config;
|
||||
|
||||
pub struct OptimizationParams {
|
||||
pub expert: String,
|
||||
pub set_file: String,
|
||||
pub symbol: String,
|
||||
pub from_date: String,
|
||||
pub to_date: String,
|
||||
pub deposit: u32,
|
||||
pub model: u8,
|
||||
pub leverage: u32,
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
impl Default for OptimizationParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
expert: String::new(),
|
||||
set_file: String::new(),
|
||||
symbol: "XAUUSD".to_string(),
|
||||
from_date: String::new(),
|
||||
to_date: String::new(),
|
||||
deposit: 10000,
|
||||
model: 0,
|
||||
leverage: 500,
|
||||
currency: "USD".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OptimizationResult {
|
||||
pub success: bool,
|
||||
pub job_id: String,
|
||||
pub pid: u32,
|
||||
pub log_file: PathBuf,
|
||||
pub combinations: u64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub struct OptimizationRunner {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl OptimizationRunner {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub async fn run(&self, params: OptimizationParams) -> Result<OptimizationResult> {
|
||||
// Validate required fields
|
||||
if params.expert.is_empty() {
|
||||
return Err(anyhow!("expert is required"));
|
||||
}
|
||||
if params.set_file.is_empty() {
|
||||
return Err(anyhow!("set_file is required"));
|
||||
}
|
||||
if params.from_date.is_empty() {
|
||||
return Err(anyhow!("from_date is required"));
|
||||
}
|
||||
if params.to_date.is_empty() {
|
||||
return Err(anyhow!("to_date is required"));
|
||||
}
|
||||
|
||||
let set_path = Path::new(¶ms.set_file);
|
||||
if !set_path.exists() {
|
||||
return Err(anyhow!("Set file not found: {}", params.set_file));
|
||||
}
|
||||
|
||||
// Generate job ID and log file
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let job_id = format!("opt_{}", timestamp);
|
||||
let log_file = PathBuf::from(format!("/tmp/mt5opt_{}.log", timestamp));
|
||||
|
||||
// Count combinations
|
||||
let combinations = self.count_combinations(¶ms.set_file)?;
|
||||
|
||||
// Get paths
|
||||
let mt5_dir = self.config.terminal_dir.as_ref()
|
||||
.ok_or_else(|| anyhow!("terminal_dir not configured"))?;
|
||||
let wine_exe = self.config.wine_executable.as_ref()
|
||||
.ok_or_else(|| anyhow!("wine_executable not configured"))?;
|
||||
|
||||
// Write .set file as UTF-16LE with BOM directly to MT5 tester directory
|
||||
let wine_prefix_dir = self.get_wine_prefix_dir(mt5_dir)?;
|
||||
let tester_dir = wine_prefix_dir.join("drive_c/Program Files/MetaTrader 5/MQL5/Profiles/Tester");
|
||||
fs::create_dir_all(&tester_dir)?;
|
||||
let dst_set_file = tester_dir.join(format!("{}.set", params.expert));
|
||||
self.write_utf16le_set(¶ms.set_file, &dst_set_file)?;
|
||||
|
||||
// Reset OptMode in terminal.ini
|
||||
self.reset_optmode(mt5_dir)?;
|
||||
|
||||
// Get Wine prefix directory
|
||||
let wine_prefix_dir = self.get_wine_prefix_dir(mt5_dir)?;
|
||||
|
||||
// Build optimization INI
|
||||
let ini_path = wine_prefix_dir.join("drive_c/mt5mcp_backtest.ini");
|
||||
let ini_content = format!(r#"[Tester]
|
||||
Expert={}
|
||||
Symbol={}
|
||||
Period=M5
|
||||
Deposit={}
|
||||
Currency={}
|
||||
Leverage={}
|
||||
Model={}
|
||||
FromDate={}
|
||||
ToDate={}
|
||||
Report=C:\mt5mcp_opt_report
|
||||
Optimization=2
|
||||
ExpertParameters={}.set
|
||||
ShutdownTerminal=1
|
||||
"#, params.expert, params.symbol, params.deposit, params.currency,
|
||||
params.leverage, params.model, params.from_date, params.to_date, params.expert);
|
||||
fs::write(&ini_path, ini_content)?;
|
||||
|
||||
// Build batch file
|
||||
let batch_path = wine_prefix_dir.join("drive_c/mt5mcp_run.bat");
|
||||
let batch_content = format!(r#"@echo off
|
||||
"C:\Program Files\MetaTrader 5\terminal64.exe" /config:C:\mt5mcp_backtest.ini
|
||||
"#);
|
||||
fs::write(&batch_path, batch_content)?;
|
||||
|
||||
// Launch detached process
|
||||
let cmd = format!("cmd.exe /c 'C:\\mt5mcp_run.bat'");
|
||||
let child = Command::new(wine_exe)
|
||||
.arg(&cmd)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
let pid = child.id();
|
||||
|
||||
// Write job metadata
|
||||
self.write_job_metadata(&job_id, pid, ¶ms, &log_file, combinations, &wine_prefix_dir)?;
|
||||
|
||||
Ok(OptimizationResult {
|
||||
success: true,
|
||||
job_id,
|
||||
pid,
|
||||
log_file,
|
||||
combinations,
|
||||
message: format!("Optimization launched (pid: {}). Runs for 2-6 hours. Do NOT kill this process.", pid),
|
||||
})
|
||||
}
|
||||
|
||||
fn count_combinations(&self, set_file: &str) -> Result<u64> {
|
||||
let content = fs::read_to_string(set_file)?;
|
||||
let mut total: u64 = 1;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with(';') || !line.contains('=') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Format: param=value||start||step||stop||Y
|
||||
let parts: Vec<&str> = line.split("||").collect();
|
||||
if parts.len() >= 5 && parts.last().unwrap().trim().to_uppercase() == "Y" {
|
||||
if let (Ok(start), Ok(step), Ok(stop)) = (
|
||||
parts[1].trim().parse::<f64>(),
|
||||
parts[2].trim().parse::<f64>(),
|
||||
parts[3].trim().parse::<f64>(),
|
||||
) {
|
||||
if step > 0.0 {
|
||||
let count = ((stop - start) / step).max(0.0) as u64 + 1;
|
||||
total = total.saturating_mul(count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(total.max(1))
|
||||
}
|
||||
|
||||
fn write_utf16le_set(&self, src: &str, dst: &Path) -> Result<()> {
|
||||
let content = fs::read_to_string(src)?;
|
||||
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = dst.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Write UTF-16LE with BOM
|
||||
let mut utf16_content: Vec<u16> = vec![0xFEFF]; // BOM
|
||||
utf16_content.extend(content.encode_utf16());
|
||||
|
||||
let bytes: Vec<u8> = utf16_content.iter()
|
||||
.flat_map(|&c| vec![(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8])
|
||||
.collect();
|
||||
|
||||
fs::write(dst, bytes)?;
|
||||
|
||||
// Make read-only
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(dst, fs::Permissions::from_mode(0o444))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset_optmode(&self, mt5_dir: &str) -> Result<()> {
|
||||
let terminal_ini = Path::new(mt5_dir).join("terminal.ini");
|
||||
|
||||
if !terminal_ini.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&terminal_ini)?;
|
||||
let updated = content
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.starts_with("OptMode=") {
|
||||
"OptMode=0".to_string()
|
||||
} else if line.starts_with("LastOptimization=") {
|
||||
String::new()
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
})
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
fs::write(&terminal_ini, updated)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_wine_prefix_dir(&self, mt5_dir: &str) -> Result<PathBuf> {
|
||||
let path = Path::new(mt5_dir);
|
||||
// Go up two levels: .../drive_c/Program Files/MetaTrader 5 -> .../drive_c
|
||||
let prefix_dir = path
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.ok_or_else(|| anyhow!("Cannot determine Wine prefix from terminal_dir"))?;
|
||||
Ok(prefix_dir.to_path_buf())
|
||||
}
|
||||
|
||||
fn write_job_metadata(
|
||||
&self,
|
||||
job_id: &str,
|
||||
pid: u32,
|
||||
params: &OptimizationParams,
|
||||
log_file: &Path,
|
||||
combinations: u64,
|
||||
wine_prefix: &Path,
|
||||
) -> Result<()> {
|
||||
let jobs_dir = Path::new(".mt5mcp_jobs");
|
||||
fs::create_dir_all(jobs_dir)?;
|
||||
|
||||
let meta_path = jobs_dir.join(format!("{}.json", job_id));
|
||||
let started_at = Utc::now().to_rfc3339();
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"job_id": job_id,
|
||||
"pid": pid,
|
||||
"expert": params.expert,
|
||||
"symbol": params.symbol,
|
||||
"from_date": params.from_date,
|
||||
"to_date": params.to_date,
|
||||
"set_file": params.set_file,
|
||||
"combinations": combinations,
|
||||
"log_file": log_file.to_string_lossy(),
|
||||
"wine_prefix": wine_prefix.to_string_lossy(),
|
||||
"started_at": started_at,
|
||||
});
|
||||
|
||||
fs::write(&meta_path, serde_json::to_string_pretty(&metadata)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_job_status(&self, job_id: &str) -> Result<serde_json::Value> {
|
||||
let jobs_dir = Path::new(".mt5mcp_jobs");
|
||||
let meta_path = jobs_dir.join(format!("{}.json", job_id));
|
||||
|
||||
if !meta_path.exists() {
|
||||
return Ok(serde_json::json!({
|
||||
"status": "not_found",
|
||||
"message": format!("Job {} not found", job_id)
|
||||
}));
|
||||
}
|
||||
|
||||
let meta: serde_json::Value = serde_json::from_str(&fs::read_to_string(&meta_path)?)?;
|
||||
let pid = meta.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
|
||||
// Check if process is still running
|
||||
let is_running = self.is_process_running(pid);
|
||||
|
||||
// Check for completion marker in log
|
||||
let log_file = meta.get("log_file").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let is_complete = if !log_file.is_empty() && Path::new(log_file).exists() {
|
||||
fs::read_to_string(log_file)
|
||||
.map(|content| content.contains("Optimization complete"))
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let status = if is_complete {
|
||||
"completed"
|
||||
} else if is_running {
|
||||
"running"
|
||||
} else {
|
||||
"stopped"
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": status,
|
||||
"job_id": job_id,
|
||||
"pid": pid,
|
||||
"expert": meta.get("expert"),
|
||||
"symbol": meta.get("symbol"),
|
||||
"started_at": meta.get("started_at"),
|
||||
"log_file": log_file,
|
||||
}))
|
||||
}
|
||||
|
||||
fn is_process_running(&self, pid: u32) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Command::new("kill")
|
||||
.args(["-0", &pid.to_string()])
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// Windows implementation would use different method
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn list_jobs(&self) -> Result<Vec<serde_json::Value>> {
|
||||
let jobs_dir = Path::new(".mt5mcp_jobs");
|
||||
let mut jobs = Vec::new();
|
||||
|
||||
if jobs_dir.exists() {
|
||||
for entry in fs::read_dir(jobs_dir)? {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "json").unwrap_or(false) {
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
if let Ok(meta) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let job_id = path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let pid = meta.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
let is_running = self.is_process_running(pid);
|
||||
|
||||
jobs.push(serde_json::json!({
|
||||
"job_id": job_id,
|
||||
"expert": meta.get("expert"),
|
||||
"status": if is_running { "running" } else { "stopped" },
|
||||
"started_at": meta.get("started_at"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(jobs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OptimizationPass {
|
||||
pub pass: u32,
|
||||
pub profit: f64,
|
||||
pub total_trades: u32,
|
||||
pub profit_factor: f64,
|
||||
pub expected_payoff: f64,
|
||||
pub drawdown_pct: f64,
|
||||
pub params: HashMap<String, String>,
|
||||
}
|
||||
|
||||
pub struct OptimizationParser;
|
||||
|
||||
impl OptimizationParser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn parse_job(&self, job_id: &str) -> Result<Vec<OptimizationPass>> {
|
||||
let jobs_dir = Path::new(".mt5mcp_jobs");
|
||||
let meta_path = jobs_dir.join(format!("{}.json", job_id));
|
||||
|
||||
if !meta_path.exists() {
|
||||
return Err(anyhow!("Job not found: {}. Check .mt5mcp_jobs/", job_id));
|
||||
}
|
||||
|
||||
let meta: serde_json::Value = serde_json::from_str(&fs::read_to_string(&meta_path)?)?;
|
||||
let wine_prefix = meta.get("wine_prefix")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("wine_prefix not in job metadata"))?;
|
||||
|
||||
let base_path = Path::new(wine_prefix).join("drive_c/mt5mcp_opt_report");
|
||||
|
||||
// Try different extensions
|
||||
for ext in &[".htm", ".htm.xml", ".html"] {
|
||||
let candidate = base_path.with_extension(ext.trim_start_matches('.'));
|
||||
if candidate.exists() {
|
||||
return self.parse_file(&candidate);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"Optimization report not found. Expected: {}.htm or {}.htm.xml\nIs MT5 optimization still running?",
|
||||
base_path.display(),
|
||||
base_path.display()
|
||||
))
|
||||
}
|
||||
|
||||
pub fn parse_file(&self, path: &Path) -> Result<Vec<OptimizationPass>> {
|
||||
let format = self.detect_format(path);
|
||||
let text = self.read_text(path)?;
|
||||
|
||||
match format {
|
||||
"xml" => self.parse_xml(&text),
|
||||
_ => self.parse_html(&text),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_format(&self, path: &Path) -> &str {
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str.ends_with(".xml") || path_str.ends_with(".htm.xml") {
|
||||
return "xml";
|
||||
}
|
||||
|
||||
if let Ok(header) = fs::read(path) {
|
||||
let header = &header[..header.len().min(512)];
|
||||
if header.windows(5).any(|w| w == b"<?xml") || header.windows(8).any(|w| w == b"Workbook") {
|
||||
return "xml";
|
||||
}
|
||||
}
|
||||
|
||||
"html"
|
||||
}
|
||||
|
||||
fn read_text(&self, path: &Path) -> Result<String> {
|
||||
let raw = fs::read(path)?;
|
||||
|
||||
// Try UTF-16 first (common for MT5 reports)
|
||||
if raw.len() >= 2 {
|
||||
if raw[0] == 0xFF && raw[1] == 0xFE {
|
||||
// UTF-16 LE with BOM
|
||||
let u16_vec: Vec<u16> = raw[2..].chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
return Ok(String::from_utf16_lossy(&u16_vec));
|
||||
} else if raw[0] == 0xFE && raw[1] == 0xFF {
|
||||
// UTF-16 BE with BOM
|
||||
let u16_vec: Vec<u16> = raw[2..].chunks_exact(2)
|
||||
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
return Ok(String::from_utf16_lossy(&u16_vec));
|
||||
}
|
||||
}
|
||||
|
||||
// Try UTF-8, then fallback to lossy
|
||||
if let Ok(text) = String::from_utf8(raw.clone()) {
|
||||
return Ok(text);
|
||||
}
|
||||
|
||||
// Try UTF-16 without BOM
|
||||
if raw.len() % 2 == 0 {
|
||||
let u16_vec: Vec<u16> = raw.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
let text = String::from_utf16_lossy(&u16_vec);
|
||||
if text.chars().any(|c| c.is_ascii_alphanumeric()) {
|
||||
return Ok(text);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&raw).to_string())
|
||||
}
|
||||
|
||||
fn parse_html(&self, text: &str) -> Result<Vec<OptimizationPass>> {
|
||||
let mut results = Vec::new();
|
||||
let mut headers: Vec<String> = Vec::new();
|
||||
|
||||
// Find all table rows
|
||||
let row_regex = regex::Regex::new(r"<tr[^>]*>(.*?)</tr>")?;
|
||||
let cell_regex = regex::Regex::new(r"<t[dh][^>]*>(.*?)</t[dh]>")?;
|
||||
let tag_regex = regex::Regex::new(r"<[^>]+>")?;
|
||||
|
||||
for row_caps in row_regex.captures_iter(text) {
|
||||
let row = &row_caps[1];
|
||||
let cells: Vec<String> = cell_regex.captures_iter(row)
|
||||
.map(|c| {
|
||||
let cell = &c[1];
|
||||
tag_regex.replace_all(cell, "").trim().to_string().replace(',', "")
|
||||
})
|
||||
.collect();
|
||||
|
||||
if cells.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Header row detection
|
||||
if headers.is_empty() && cells[0].to_lowercase().contains("pass") {
|
||||
headers = cells;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Data row
|
||||
if !headers.is_empty() && cells[0].parse::<u32>().is_ok() {
|
||||
let row_map: HashMap<String, String> = headers.iter()
|
||||
.zip(cells.iter())
|
||||
.map(|(h, c)| (h.to_lowercase().replace(' ', "_"), c.clone()))
|
||||
.collect();
|
||||
|
||||
if let Some(pass) = self.row_to_pass(&row_map) {
|
||||
results.push(pass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn parse_xml(&self, text: &str) -> Result<Vec<OptimizationPass>> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
// Parse SpreadsheetML XML
|
||||
let doc = roxmltree::Document::parse(text)?;
|
||||
|
||||
// Find all rows in Worksheet/Table
|
||||
for node in doc.descendants() {
|
||||
if node.has_tag_name(("http://schemas.microsoft.com/office/excel/2003/xml", "Row")) ||
|
||||
node.has_tag_name("Row") {
|
||||
let cells: Vec<String> = node.children()
|
||||
.filter(|n: &roxmltree::Node<'_, '_>| {
|
||||
n.has_tag_name(("http://schemas.microsoft.com/office/excel/2003/xml", "Cell")) ||
|
||||
n.has_tag_name("Cell") ||
|
||||
n.has_tag_name(("http://schemas.microsoft.com/office/excel/2003/xml", "Data")) ||
|
||||
n.has_tag_name("Data")
|
||||
})
|
||||
.map(|n| n.text().unwrap_or("").trim().to_string().replace(',', ""))
|
||||
.collect();
|
||||
|
||||
if cells.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if first cell is a pass number
|
||||
if let Ok(pass_num) = cells[0].parse::<u32>() {
|
||||
if pass_num > 0 {
|
||||
let mut row_map = HashMap::new();
|
||||
|
||||
// Standard MT5 optimization report columns
|
||||
let headers = vec![
|
||||
"pass", "result", "profit", "total_trades", "profit_factor",
|
||||
"expected_payoff", "drawdown_pct", "recovery_factor", "sharpe_ratio",
|
||||
"custom", "consecutive_wins", "consecutive_losses",
|
||||
];
|
||||
|
||||
for (i, cell) in cells.iter().enumerate() {
|
||||
if let Some(header) = headers.get(i) {
|
||||
row_map.insert(header.to_string(), cell.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pass) = self.row_to_pass(&row_map) {
|
||||
results.push(pass);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn row_to_pass(&self, row: &HashMap<String, String>) -> Option<OptimizationPass> {
|
||||
let pass = row.get("pass").or_else(|| row.get("#"))
|
||||
.and_then(|v| v.parse().ok())?;
|
||||
|
||||
let profit = row.get("profit").or_else(|| row.get("total_net_profit"))
|
||||
.and_then(|v| v.replace(' ', "").parse().ok())?;
|
||||
|
||||
let total_trades = row.get("total_trades").or_else(|| row.get("trades"))
|
||||
.and_then(|v| v.parse().ok())?;
|
||||
|
||||
let profit_factor = row.get("profit_factor")
|
||||
.and_then(|v| v.parse().ok())?;
|
||||
|
||||
let expected_payoff = row.get("expected_payoff")
|
||||
.and_then(|v| v.parse().ok())?;
|
||||
|
||||
let drawdown_pct = row.get("drawdown_pct").or_else(|| row.get("max_drawdown"))
|
||||
.and_then(|v| v.trim_end_matches('%').trim().parse().ok())?;
|
||||
|
||||
// Extract parameter values from row
|
||||
let params: HashMap<String, String> = row.iter()
|
||||
.filter(|(k, _)| ![
|
||||
"pass", "result", "profit", "total_trades", "profit_factor",
|
||||
"expected_payoff", "drawdown_pct", "max_drawdown", "recovery_factor",
|
||||
"sharpe_ratio", "custom", "consecutive_wins", "consecutive_losses"
|
||||
].contains(&k.as_str()))
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
|
||||
Some(OptimizationPass {
|
||||
pass,
|
||||
profit,
|
||||
total_trades,
|
||||
profit_factor,
|
||||
expected_payoff,
|
||||
drawdown_pct,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn find_best_pass<'a>(&self, passes: &'a [OptimizationPass], criteria: &str) -> Option<&'a OptimizationPass> {
|
||||
match criteria {
|
||||
"profit" => passes.iter().max_by(|a, b| a.profit.partial_cmp(&b.profit).unwrap()),
|
||||
"profit_factor" => passes.iter().max_by(|a, b| a.profit_factor.partial_cmp(&b.profit_factor).unwrap()),
|
||||
"sharpe" => passes.iter().max_by(|a, b| {
|
||||
let a_sharpe = a.params.get("sharpe_ratio").and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
|
||||
let b_sharpe = b.params.get("sharpe_ratio").and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
|
||||
a_sharpe.partial_cmp(&b_sharpe).unwrap()
|
||||
}),
|
||||
"drawdown" => passes.iter().min_by(|a, b| a.drawdown_pct.partial_cmp(&b.drawdown_pct).unwrap()),
|
||||
_ => passes.iter().max_by(|a, b| a.profit.partial_cmp(&b.profit).unwrap()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ pub struct BacktestParams {
|
||||
pub skip_compile: bool,
|
||||
pub skip_clean: bool,
|
||||
pub skip_analyze: bool,
|
||||
#[allow(dead_code)]
|
||||
pub deep_analyze: bool,
|
||||
pub shutdown: bool,
|
||||
pub kill_existing: bool,
|
||||
@@ -250,7 +251,7 @@ start terminal64.exe /config:"C:\Program Files\MetaTrader 5\backtest_config.ini"
|
||||
let bat_path = wine_prefix.join("drive_c").join("_mt5mcp_run.bat");
|
||||
fs::write(&bat_path, bat_content)?;
|
||||
|
||||
let cmd = format!("cmd.exe /c 'C:\\_mt5mcp_run.bat'");
|
||||
let _cmd = format!("cmd.exe /c 'C:\\_mt5mcp_run.bat'");
|
||||
|
||||
if params.shutdown {
|
||||
let output = Command::new("timeout")
|
||||
@@ -346,7 +347,7 @@ start terminal64.exe /config:"C:\Program Files\MetaTrader 5\backtest_config.ini"
|
||||
}
|
||||
|
||||
async fn kill_mt5(&self) -> Result<()> {
|
||||
let output = Command::new("pkill")
|
||||
let _output = Command::new("pkill")
|
||||
.args(&["-TERM", "-f", "terminal64\\.exe"])
|
||||
.output()?;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Stage {
|
||||
Compile,
|
||||
@@ -11,6 +12,7 @@ pub enum Stage {
|
||||
Done,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Stage {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
@@ -35,8 +37,10 @@ impl Stage {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct StageExecutor;
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl StageExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
@@ -74,12 +78,14 @@ impl StageExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct StageResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub output: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl StageResult {
|
||||
pub fn success() -> Self {
|
||||
Self {
|
||||
|
||||
@@ -3,8 +3,12 @@ use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use crate::analytics::DealAnalyzer;
|
||||
use crate::compile::MqlCompiler;
|
||||
use crate::models::Config;
|
||||
use crate::models::deals::Deal;
|
||||
use crate::models::metrics::Metrics;
|
||||
use crate::optimization::{OptimizationParams, OptimizationParser, OptimizationRunner};
|
||||
use crate::pipeline::backtest::{BacktestParams, BacktestPipeline};
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -31,6 +35,28 @@ impl ToolHandler {
|
||||
"prune_reports" => self.handle_prune_reports(args).await,
|
||||
"list_set_files" => self.handle_list_set_files().await,
|
||||
"describe_sweep" => self.handle_describe_sweep(args).await,
|
||||
// Optimization tools
|
||||
"run_optimization" => self.handle_run_optimization(args).await,
|
||||
"get_optimization_status" => self.handle_get_optimization_status(args).await,
|
||||
"get_optimization_results" => self.handle_get_optimization_results(args).await,
|
||||
"list_jobs" => self.handle_list_jobs().await,
|
||||
// Analysis tools
|
||||
"analyze_report" => self.handle_analyze_report(args).await,
|
||||
"compare_baseline" => self.handle_compare_baseline(args).await,
|
||||
// Set file tools
|
||||
"read_set_file" => self.handle_read_set_file(args).await,
|
||||
"write_set_file" => self.handle_write_set_file(args).await,
|
||||
"patch_set_file" => self.handle_patch_set_file(args).await,
|
||||
"clone_set_file" => self.handle_clone_set_file(args).await,
|
||||
"diff_set_files" => self.handle_diff_set_files(args).await,
|
||||
"set_from_optimization" => self.handle_set_from_optimization(args).await,
|
||||
// Utility tools
|
||||
"tail_log" => self.handle_tail_log(args).await,
|
||||
"archive_report" => self.handle_archive_report(args).await,
|
||||
"archive_all_reports" => self.handle_archive_all_reports(args).await,
|
||||
"promote_to_baseline" => self.handle_promote_to_baseline(args).await,
|
||||
"get_history" => self.handle_get_history(args).await,
|
||||
"annotate_history" => self.handle_annotate_history(args).await,
|
||||
_ => Ok(json!({
|
||||
"content": [{ "type": "text", "text": format!("Tool '{}' not implemented", name) }],
|
||||
"isError": true
|
||||
@@ -461,4 +487,641 @@ impl ToolHandler {
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
// Optimization handlers
|
||||
async fn handle_run_optimization(&self, 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, // Always 0 for optimization
|
||||
leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32,
|
||||
currency: args.get("currency").and_then(|v| v.as_str()).unwrap_or("USD").to_string(),
|
||||
};
|
||||
|
||||
let runner = OptimizationRunner::new(self.config.clone());
|
||||
let result = runner.run(params).await?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": result.success,
|
||||
"job_id": result.job_id,
|
||||
"pid": result.pid,
|
||||
"log_file": result.log_file.to_string_lossy(),
|
||||
"combinations": result.combinations,
|
||||
"message": result.message,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_get_optimization_status(&self, args: &Value) -> Result<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(self.config.clone());
|
||||
let status = runner.get_job_status(job_id)?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": status.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_get_optimization_results(&self, args: &Value) -> Result<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;
|
||||
|
||||
// Find best pass
|
||||
let best = parser.find_best_pass(&passes, sort_by);
|
||||
|
||||
let mut sorted_passes = passes.clone();
|
||||
sorted_passes.sort_by(|a, b| b.profit.partial_cmp(&a.profit).unwrap());
|
||||
sorted_passes.truncate(top_n);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"total_passes": passes.len(),
|
||||
"top_passes": sorted_passes,
|
||||
"best": best,
|
||||
"sort_by": sort_by,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_list_jobs(&self) -> Result<Value> {
|
||||
let runner = OptimizationRunner::new(self.config.clone());
|
||||
let jobs = runner.list_jobs()?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({ "jobs": jobs }).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
// Analysis handlers
|
||||
async fn handle_analyze_report(&self, args: &Value) -> Result<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 = std::path::Path::new(report_dir).join("deals.csv");
|
||||
let metrics_json = std::path::Path::new(report_dir).join("metrics.json");
|
||||
|
||||
if !deals_csv.exists() {
|
||||
return Err(anyhow::anyhow!("deals.csv not found in {}", report_dir));
|
||||
}
|
||||
|
||||
// Read deals
|
||||
let deals = self.read_deals_from_csv(&deals_csv)?;
|
||||
|
||||
// Read metrics
|
||||
let metrics = if metrics_json.exists() {
|
||||
let content = fs::read_to_string(&metrics_json)?;
|
||||
serde_json::from_str(&content)?
|
||||
} else {
|
||||
Metrics::default()
|
||||
};
|
||||
|
||||
let _strategy = args.get("strategy").and_then(|v| v.as_str()).unwrap_or("grid");
|
||||
let _deep = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.analyze(&deals, &metrics);
|
||||
|
||||
// Write analysis.json
|
||||
let analysis_path = std::path::Path::new(report_dir).join("analysis.json");
|
||||
fs::write(&analysis_path, serde_json::to_string_pretty(&result)?)?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"analysis_file": analysis_path.to_string_lossy(),
|
||||
"summary": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_deals_from_csv(&self, path: &std::path::Path) -> Result<Vec<Deal>> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut deals = Vec::new();
|
||||
|
||||
let mut lines = content.lines();
|
||||
let _header = lines.next(); // Skip header
|
||||
|
||||
for line in lines {
|
||||
let parts: Vec<&str> = line.split(',').collect();
|
||||
if parts.len() >= 12 {
|
||||
deals.push(Deal {
|
||||
time: parts[0].to_string(),
|
||||
deal: parts[1].to_string(),
|
||||
symbol: parts[2].to_string(),
|
||||
deal_type: parts[3].to_string(),
|
||||
entry: parts[4].to_string(),
|
||||
volume: parts[5].parse().unwrap_or(0.0),
|
||||
price: parts[6].parse().unwrap_or(0.0),
|
||||
order: parts[7].to_string(),
|
||||
commission: parts[8].parse().unwrap_or(0.0),
|
||||
swap: parts[9].parse().unwrap_or(0.0),
|
||||
profit: parts[10].parse().unwrap_or(0.0),
|
||||
balance: parts[11].parse().unwrap_or(0.0),
|
||||
comment: parts.get(12).unwrap_or(&"").to_string(),
|
||||
magic: parts.get(13).map(|s| s.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deals)
|
||||
}
|
||||
|
||||
async fn handle_compare_baseline(&self, args: &Value) -> Result<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 = std::path::Path::new("config/baseline.json");
|
||||
let metrics_path = std::path::Path::new(report_dir).join("metrics.json");
|
||||
|
||||
if !baseline_path.exists() {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": "No baseline.json found in config/" }],
|
||||
"isError": false
|
||||
}));
|
||||
}
|
||||
|
||||
let baseline: Value = serde_json::from_str(&fs::read_to_string(baseline_path)?)?;
|
||||
let current: Value = serde_json::from_str(&fs::read_to_string(metrics_path)?)?;
|
||||
|
||||
let comparison = json!({
|
||||
"baseline": baseline,
|
||||
"current": current,
|
||||
"improvements": {
|
||||
"profit": current.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0)
|
||||
- baseline.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"drawdown": current.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0)
|
||||
- baseline.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": comparison.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
// Set file handlers
|
||||
async fn handle_read_set_file(&self, args: &Value) -> Result<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
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_write_set_file(&self, 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
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_patch_set_file(&self, 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"))?;
|
||||
|
||||
// Read existing file
|
||||
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()
|
||||
};
|
||||
|
||||
// Find and patch the parameter
|
||||
let mut found = false;
|
||||
for line in &mut lines {
|
||||
if line.starts_with(&format!("{}:", key)) {
|
||||
*line = format!("{}: {}", key, new_value);
|
||||
found = true;
|
||||
patched_count += 1;
|
||||
break;
|
||||
} else if line.starts_with(&format!("{}=", key)) {
|
||||
*line = format!("{}={}", key, new_value);
|
||||
found = true;
|
||||
patched_count += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If not found, add it
|
||||
if !found {
|
||||
lines.push(format!("{}: {}", key, new_value));
|
||||
patched_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(path, lines.join("\n"))?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"path": path,
|
||||
"parameters_patched": patched_count,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_clone_set_file(&self, args: &Value) -> Result<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
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_diff_set_files(&self, 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
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_set_from_optimization(&self, 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
|
||||
}))
|
||||
}
|
||||
|
||||
// Utility handlers
|
||||
async fn handle_tail_log(&self, 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 = std::path::Path::new(".mt5mcp_jobs");
|
||||
let meta_path = jobs_dir.join(format!("{}.json", jid));
|
||||
let meta: Value = serde_json::from_str(&fs::read_to_string(meta_path)?)?;
|
||||
meta.get("log_file").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
} else {
|
||||
args.get("file").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||
};
|
||||
|
||||
let log_path = log_path.ok_or_else(|| anyhow::anyhow!("Could not determine log file"))?;
|
||||
|
||||
let content = fs::read_to_string(&log_path)?;
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
let start = all_lines.len().saturating_sub(lines);
|
||||
let last_lines = &all_lines[start..];
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": last_lines.join("\n") }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_archive_report(&self, args: &Value) -> Result<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 = std::path::Path::new(".mt5mcp_history");
|
||||
fs::create_dir_all(history_dir)?;
|
||||
|
||||
let report_name = std::path::Path::new(report_dir).file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
let archive_path = history_dir.join(format!("{}.tar.gz", report_name));
|
||||
|
||||
// Create tarball
|
||||
let status = std::process::Command::new("tar")
|
||||
.args(["-czf", &archive_path.to_string_lossy(), "-C",
|
||||
std::path::Path::new(report_dir).parent().unwrap().to_str().unwrap(),
|
||||
report_name])
|
||||
.status()?;
|
||||
|
||||
if delete_after && status.success() {
|
||||
fs::remove_dir_all(report_dir)?;
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": status.success(),
|
||||
"archive_path": archive_path.to_string_lossy(),
|
||||
"deleted": delete_after && status.success(),
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_archive_all_reports(&self, args: &Value) -> Result<Value> {
|
||||
let keep_last = args.get("keep_last").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
|
||||
|
||||
let reports_dir = self.config.reports_dir();
|
||||
let history_dir = std::path::Path::new(".mt5mcp_history");
|
||||
fs::create_dir_all(history_dir)?;
|
||||
|
||||
let mut archived = 0;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&reports_dir) {
|
||||
let mut entries: Vec<_> = entries.flatten().collect();
|
||||
entries.sort_by(|a, b| {
|
||||
b.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH)
|
||||
.cmp(&a.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH))
|
||||
});
|
||||
|
||||
for entry in entries.into_iter().skip(keep_last) {
|
||||
let path = entry.path();
|
||||
if path.is_dir() && !path.to_string_lossy().ends_with("_opt") {
|
||||
let report_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("unknown");
|
||||
let archive_path = history_dir.join(format!("{}.tar.gz", report_name));
|
||||
|
||||
let _ = std::process::Command::new("tar")
|
||||
.args(["-czf", &archive_path.to_string_lossy(), "-C",
|
||||
path.parent().unwrap().to_str().unwrap(), report_name])
|
||||
.status();
|
||||
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
archived += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"archived": archived,
|
||||
"kept": keep_last,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_promote_to_baseline(&self, args: &Value) -> Result<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 = std::path::Path::new(report_dir).join("metrics.json");
|
||||
let baseline_path = std::path::Path::new("config/baseline.json");
|
||||
|
||||
fs::copy(&metrics_path, &baseline_path)?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"baseline_file": baseline_path.to_string_lossy(),
|
||||
"source": metrics_path.to_string_lossy(),
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_get_history(&self, args: &Value) -> Result<Value> {
|
||||
let _limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
|
||||
let history_dir = std::path::Path::new(".mt5mcp_history");
|
||||
let mut history = Vec::new();
|
||||
|
||||
if history_dir.exists() {
|
||||
for entry in fs::read_dir(history_dir)? {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "tar.gz").unwrap_or(false) {
|
||||
let name = path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let metadata = entry.metadata()?;
|
||||
let modified = metadata.modified()?;
|
||||
let size = metadata.len();
|
||||
|
||||
history.push(json!({
|
||||
"name": name,
|
||||
"path": path.to_string_lossy(),
|
||||
"size": size,
|
||||
"archived_at": modified.elapsed().map(|e| e.as_secs()).unwrap_or(0),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"total_archived": history.len(),
|
||||
"history": history,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_annotate_history(&self, args: &Value) -> Result<Value> {
|
||||
let report_name = args.get("report_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_name is required"))?;
|
||||
|
||||
let note = args.get("note")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let notes_path = std::path::Path::new(".mt5mcp_history").join("notes.json");
|
||||
|
||||
let mut notes: serde_json::Map<String, Value> = if notes_path.exists() {
|
||||
serde_json::from_str(&fs::read_to_string(¬es_path)?)?
|
||||
} else {
|
||||
serde_json::Map::new()
|
||||
};
|
||||
|
||||
notes.insert(report_name.to_string(), json!(note));
|
||||
fs::write(¬es_path, serde_json::to_string_pretty(¬es)?)?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"report": report_name,
|
||||
"note": note,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user