From 6ce880894852add56d3001d11a37c054e0c282f4 Mon Sep 17 00:00:00 2001 From: Devid HW Date: Mon, 20 Apr 2026 02:25:07 +0700 Subject: [PATCH] v1.30.0: Add 10 Wine/MT5 debugging tools New debugging/diagnostics tools for crash investigation: - diagnose_wine: Check Wine installation and prefix health - get_mt5_logs: Get terminal/tester/metaeditor logs - search_mt5_errors: Search logs for error patterns - check_mt5_process: Check MT5 process status - kill_mt5_process: Kill stuck MT5 processes - check_system_resources: Check disk/memory/CPU - validate_mt5_config: Validate MT5 configuration - get_wine_prefix_info: Wine prefix details - get_backtest_crash_info: Investigate backtest failures Total tools: 85 Documentation updated in README.md and MCP_TOOLS.md --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 50 +- docs/MCP_TOOLS.md | 6 +- src/analytics/analyze.rs | 611 +++++++++++++++++++- src/storage/database.rs | 539 ++++++++++++++++++ src/tools/definitions/analytics.rs | 152 +++++ src/tools/definitions/mod.rs | 33 +- src/tools/definitions/reports.rs | 130 +++++ src/tools/definitions/utility.rs | 146 +++++ src/tools/handlers/analysis.rs | 331 +++++++++++ src/tools/handlers/mod.rs | 31 +- src/tools/handlers/reports.rs | 415 ++++++++++++++ src/tools/handlers/utility.rs | 875 +++++++++++++++++++++++++++++ 14 files changed, 3312 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 92d03b4..2e091a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -481,7 +481,7 @@ dependencies = [ [[package]] name = "mt5-quant" -version = "1.29.0" +version = "1.29.1" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 55d76ca..05dd816 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mt5-quant" -version = "1.29.1" +version = "1.30.0" edition = "2021" description = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP" authors = ["masdevid "] diff --git a/README.md b/README.md index 792d26d..8c19f1e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MT5-Quant -**MCP server for MT5 strategy development on macOS/Linux.** 57 tools to compile, backtest, analyze, optimize, and manage MQL5 Expert Advisors — no Windows required. +**MCP server for MT5 strategy development on macOS/Linux.** 85 tools to compile, backtest, analyze, optimize, debug crashes, and manage MQL5 Expert Advisors — no Windows required. ``` You: "Backtest MyEA Jan-Mar, what caused the February drawdown?" @@ -20,6 +20,7 @@ Claude: [compile → clean → backtest → analyze 1,847 deals] | MQL5 compilation | ✅ | ❌ | ❌ | | macOS/Linux native | ✅ | Windows only | Cloud | | Optimization | ✅ Background | ❌ | ✅ Paid | +| Crash debugging | ✅ Wine/MT5 diagnostics | ❌ | ❌ | ## Quick Install @@ -61,12 +62,12 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract | [VSCODE.md](docs/VSCODE.md) | VS Code setup | | [ANTIGRAVITY.md](docs/ANTIGRAVITY.md) | Antigravity IDE setup | | [CONFIG.md](docs/CONFIG.md) | Configuration reference | -| [TOOLS.md](docs/MCP_TOOLS.md) | All 57 tools documented | +| [TOOLS.md](docs/MCP_TOOLS.md) | All 75 tools documented | | [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design and internals | | [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Common issues | | [REMOTE_AGENTS.md](docs/REMOTE_AGENTS.md) | Linux optimization agents | -## MCP Tools (57) +## MCP Tools (75) ### Core workflow @@ -98,6 +99,21 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract Use these for targeted analysis, or `analyze_report` to run all at once. +### Deal-Level Analytics (New) + +| Tool | Description | +|------|-------------| +| `list_deals` | List individual deals with filters (type, profit range, volume, dates) | +| `search_deals_by_comment` | Full-text search in deal comments (e.g., "Layer #3") | +| `search_deals_by_magic` | Filter deals by EA magic number | +| `analyze_profit_distribution` | Profit histogram: small/medium/large wins and losses | +| `analyze_time_performance` | Performance by hour of day and day of week | +| `analyze_hold_time_distribution` | Hold time buckets + correlation with profit | +| `analyze_layer_performance` | Grid/martingale layer analysis from comments | +| `analyze_volume_vs_profit` | Volume correlation + performance by lot size | +| `analyze_costs` | Commission and swap impact on profitability | +| `analyze_efficiency` | Profit per hour/day, annualized return, trade frequency | + ### Monitoring | Tool | Description | @@ -114,6 +130,14 @@ Use these for targeted analysis, or `analyze_report` to run all at once. | `list_reports` | Compact table of all runs with key metrics — no full analysis needed | | `get_latest_report` | Get most recent report with optional equity chart | | `search_reports` | Find reports by EA, symbol, date range, or profit criteria | +| `get_report_by_id` | Get specific report by ID with equity chart | +| `get_reports_summary` | Aggregate stats: counts, averages, pass rates | +| `get_best_reports` | Top N reports sorted by any metric (profit factor, drawdown, etc.) | +| `search_reports_by_tags` | Find reports by tags | +| `search_reports_by_date_range` | Query by backtest date range | +| `search_reports_by_notes` | Full-text search in report notes | +| `get_reports_by_set_file` | Find all reports using a specific .set file | +| `get_comparable_reports` | Find comparable reports (same EA/symbol/timeframe) | | `tail_log` | Read last N lines of any log; `filter=errors` to see only failures | | `prune_reports` | Delete old report directories, keep last N (skips `_opt` dirs) | @@ -143,6 +167,20 @@ Use these for targeted analysis, or `analyze_report` to run all at once. | `check_mt5_status` | Check if MT5 terminal is installed and ready | | `validate_ea_syntax` | Pre-compile syntax check without running full compilation | +### Debugging & Diagnostics (New) + +| Tool | Description | +|------|-------------| +| `diagnose_wine` | Check Wine installation, version, and prefix health | +| `get_mt5_logs` | Get MT5 terminal, tester, or MetaEditor logs with filtering | +| `search_mt5_errors` | Search logs for error patterns (crash, exception, access violation) | +| `check_mt5_process` | Check if MT5 processes are running, get PID, CPU, memory usage | +| `kill_mt5_process` | Kill stuck MT5 processes (force=true for wineserver) | +| `check_system_resources` | Check disk space, memory, CPU availability | +| `validate_mt5_config` | Validate terminal.ini and tester configuration files | +| `get_wine_prefix_info` | Get Wine prefix details: Windows version, installed programs, registry | +| `get_backtest_crash_info` | Investigate backtest failures: incomplete markers, missing deals.csv, errors | + ### Project Management | Tool | Description | @@ -192,6 +230,12 @@ Full schema: [docs/MCP_TOOLS.md](docs/MCP_TOOLS.md) Run `verify_setup` from Claude first — it checks all paths and returns actionable hints. +For crashes or unexplained failures during backtest/compile/optimization: +- `diagnose_wine` — Check Wine installation and prefix health +- `search_mt5_errors` — Find crash causes in logs +- `check_mt5_process` + `kill_mt5_process` — Detect and kill stuck processes +- `get_backtest_crash_info` — Investigate failed backtest reports + **[Full Troubleshooting Guide →](docs/TROUBLESHOOTING.md)** --- diff --git a/docs/MCP_TOOLS.md b/docs/MCP_TOOLS.md index 2040208..824ab66 100644 --- a/docs/MCP_TOOLS.md +++ b/docs/MCP_TOOLS.md @@ -2,13 +2,15 @@ Full input/output schemas for MT5-Quant tools. -> **Documentation Status:** This file documents 49 of 57 total tools. Missing: +> **Documentation Status:** This file documents 49 of 85 total tools. Missing: > - `list_experts`, `list_indicators`, `list_scripts` > - `healthcheck`, `list_symbols` -> - `search_reports`, `get_latest_report`, `list_reports`, `prune_reports`, `tail_log` +> - Reports query: `search_reports`, `get_latest_report`, `list_reports`, `prune_reports`, `tail_log`, `get_report_by_id`, `get_reports_summary`, `get_best_reports`, `search_reports_by_tags`, `search_reports_by_date_range`, `search_reports_by_notes`, `get_reports_by_set_file`, `get_comparable_reports` > - Granular analytics: `analyze_monthly_pnl`, `analyze_drawdown_events`, `analyze_top_losses`, `analyze_loss_sequences`, `analyze_position_pairs`, `analyze_direction_bias`, `analyze_streaks`, `analyze_concurrent_peak` +> - Deal analytics: `list_deals`, `search_deals_by_comment`, `search_deals_by_magic`, `analyze_profit_distribution`, `analyze_time_performance`, `analyze_hold_time_distribution`, `analyze_layer_performance`, `analyze_volume_vs_profit`, `analyze_costs`, `analyze_efficiency` > - Archive/history tools: `archive_report`, `archive_all_reports`, `get_history`, `annotate_history`, `promote_to_baseline` > - Experts search: `search_experts`, `search_indicators`, `search_scripts`, `copy_indicator_to_project`, `copy_script_to_project` +> - Debugging/diagnostics: `diagnose_wine`, `get_mt5_logs`, `search_mt5_errors`, `check_mt5_process`, `kill_mt5_process`, `check_system_resources`, `validate_mt5_config`, `get_wine_prefix_info`, `get_backtest_crash_info` --- diff --git a/src/analytics/analyze.rs b/src/analytics/analyze.rs index 21284f1..6961d34 100644 --- a/src/analytics/analyze.rs +++ b/src/analytics/analyze.rs @@ -1,4 +1,4 @@ -use chrono::{DateTime, NaiveDateTime}; +use chrono::{DateTime, Datelike, NaiveDateTime, Timelike}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -489,3 +489,612 @@ pub struct ConcurrentPeak { pub peak_open: i32, pub peak_time: String, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfitDistribution { + pub small_wins: i32, + pub medium_wins: i32, + pub large_wins: i32, + pub small_losses: i32, + pub medium_losses: i32, + pub large_losses: i32, + pub small_win_pnl: f64, + pub medium_win_pnl: f64, + pub large_win_pnl: f64, + pub small_loss_pnl: f64, + pub medium_loss_pnl: f64, + pub large_loss_pnl: f64, + pub buckets: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfitBucket { + pub range: String, + pub min: f64, + pub max: f64, + pub count: i32, + pub total_pnl: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimePerformance { + pub by_hour: Vec, + pub by_day: Vec, + pub best_hour: i32, + pub worst_hour: i32, + pub best_day: String, + pub worst_day: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HourPerformance { + pub hour: i32, + pub trades: i32, + pub wins: i32, + pub total_pnl: f64, + pub win_rate: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DayPerformance { + pub day: String, + pub day_num: i32, + pub trades: i32, + pub wins: i32, + pub total_pnl: f64, + pub win_rate: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HoldTimeAnalysis { + pub avg_hold_minutes: f64, + pub median_hold_minutes: f64, + pub buckets: Vec, + pub correlation_with_profit: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HoldTimeBucket { + pub range: String, + pub min_minutes: f64, + pub max_minutes: f64, + pub count: i32, + pub avg_profit: f64, + pub total_pnl: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LayerPerformance { + pub layer: i32, + pub trades: i32, + pub wins: i32, + pub total_pnl: f64, + pub win_rate: f64, + pub avg_volume: f64, + pub avg_profit: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VolumeAnalysis { + pub correlation_with_profit: f64, + pub by_volume_bucket: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VolumeBucket { + pub volume_range: String, + pub min_volume: f64, + pub max_volume: f64, + pub trades: i32, + pub avg_profit: f64, + pub total_pnl: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CostAnalysis { + pub total_commission: f64, + pub total_swap: f64, + pub commission_pct_of_profit: f64, + pub swap_pct_of_profit: f64, + pub avg_commission_per_trade: f64, + pub avg_swap_per_trade: f64, + pub net_profit_before_costs: f64, + pub cost_impact_on_win_rate: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EfficiencyAnalysis { + pub profit_per_hour: f64, + pub profit_per_day: f64, + pub profit_per_trade_hour: f64, + pub avg_trade_duration_hours: f64, + pub annualized_return_pct: f64, + pub trades_per_day: f64, +} + +impl DealAnalyzer { + pub fn profit_distribution(&self, deals: &[Deal]) -> ProfitDistribution { + let closed: Vec<&Deal> = deals + .iter() + .filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0) + .collect(); + + let mut small_wins = 0; + let mut medium_wins = 0; + let mut large_wins = 0; + let mut small_losses = 0; + let mut medium_losses = 0; + let mut large_losses = 0; + let mut small_win_pnl = 0.0; + let mut medium_win_pnl = 0.0; + let mut large_win_pnl = 0.0; + let mut small_loss_pnl = 0.0; + let mut medium_loss_pnl = 0.0; + let mut large_loss_pnl = 0.0; + + for deal in &closed { + let profit = deal.profit; + if profit > 0.0 { + if profit < 50.0 { + small_wins += 1; + small_win_pnl += profit; + } else if profit < 200.0 { + medium_wins += 1; + medium_win_pnl += profit; + } else { + large_wins += 1; + large_win_pnl += profit; + } + } else { + let loss = profit.abs(); + if loss < 50.0 { + small_losses += 1; + small_loss_pnl += profit; + } else if loss < 200.0 { + medium_losses += 1; + medium_loss_pnl += profit; + } else { + large_losses += 1; + large_loss_pnl += profit; + } + } + } + + // Create detailed buckets + let bucket_ranges = [ + (-999999.0, -500.0, "Loss $500+"), + (-500.0, -200.0, "Loss $200-500"), + (-200.0, -50.0, "Loss $50-200"), + (-50.0, 0.0, "Loss $0-50"), + (0.0, 50.0, "Win $0-50"), + (50.0, 200.0, "Win $50-200"), + (200.0, 500.0, "Win $200-500"), + (500.0, 999999.0, "Win $500+"), + ]; + + let mut buckets: Vec = bucket_ranges + .iter() + .map(|(min, max, range)| { + let count = closed + .iter() + .filter(|d| d.profit >= *min && d.profit < *max) + .count() as i32; + let total_pnl: f64 = closed + .iter() + .filter(|d| d.profit >= *min && d.profit < *max) + .map(|d| d.profit) + .sum(); + ProfitBucket { + range: range.to_string(), + min: *min, + max: *max, + count, + total_pnl: (total_pnl * 100.0).round() / 100.0, + } + }) + .collect(); + + // Remove empty buckets + buckets.retain(|b| b.count > 0); + + ProfitDistribution { + small_wins, + medium_wins, + large_wins, + small_losses, + medium_losses, + large_losses, + small_win_pnl: (small_win_pnl * 100.0).round() / 100.0, + medium_win_pnl: (medium_win_pnl * 100.0).round() / 100.0, + large_win_pnl: (large_win_pnl * 100.0).round() / 100.0, + small_loss_pnl: (small_loss_pnl * 100.0).round() / 100.0, + medium_loss_pnl: (medium_loss_pnl * 100.0).round() / 100.0, + large_loss_pnl: (large_loss_pnl * 100.0).round() / 100.0, + buckets, + } + } + + pub fn time_performance(&self, deals: &[Deal]) -> TimePerformance { + let closed: Vec<&Deal> = deals + .iter() + .filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0) + .collect(); + + let mut hourly: HashMap = HashMap::new(); + let mut daily: HashMap = HashMap::new(); + + for deal in &closed { + if let Some(dt) = Self::parse_datetime(&deal.time) { + let hour = dt.hour() as i32; + let day_num = dt.weekday().num_days_from_monday() as i32; + let day_name = match dt.weekday() { + chrono::Weekday::Mon => "Mon", + chrono::Weekday::Tue => "Tue", + chrono::Weekday::Wed => "Wed", + chrono::Weekday::Thu => "Thu", + chrono::Weekday::Fri => "Fri", + chrono::Weekday::Sat => "Sat", + chrono::Weekday::Sun => "Sun", + }.to_string(); + + let entry = hourly.entry(hour).or_insert((0, 0, 0.0)); + entry.0 += 1; + entry.2 += deal.profit; + if deal.profit > 0.0 { + entry.1 += 1; + } + + let day_entry = daily.entry(day_name.clone()).or_insert((0, 0, 0.0, day_num)); + day_entry.0 += 1; + day_entry.2 += deal.profit; + if deal.profit > 0.0 { + day_entry.1 += 1; + } + } + } + + let mut by_hour: Vec = hourly + .into_iter() + .map(|(hour, (trades, wins, total_pnl))| HourPerformance { + hour, + trades, + wins, + total_pnl: (total_pnl * 100.0).round() / 100.0, + win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 }, + }) + .collect(); + by_hour.sort_by_key(|h| h.hour); + + let mut by_day: Vec = daily + .into_iter() + .map(|(day, (trades, wins, total_pnl, day_num))| DayPerformance { + day: day.clone(), + day_num, + trades, + wins, + total_pnl: (total_pnl * 100.0).round() / 100.0, + win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 }, + }) + .collect(); + by_day.sort_by_key(|d| d.day_num); + + let best_hour = by_hour.iter().max_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|h| h.hour).unwrap_or(-1); + let worst_hour = by_hour.iter().min_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|h| h.hour).unwrap_or(-1); + let best_day = by_day.iter().max_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|d| d.day.clone()).unwrap_or_default(); + let worst_day = by_day.iter().min_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|d| d.day.clone()).unwrap_or_default(); + + TimePerformance { + by_hour, + by_day, + best_hour, + worst_hour, + best_day, + worst_day, + } + } + + pub fn hold_time_analysis(&self, deals: &[Deal]) -> HoldTimeAnalysis { + let mut hold_times: Vec<(f64, f64)> = Vec::new(); // (hold_minutes, profit) + let mut open_pos: HashMap> = HashMap::new(); + + for deal in deals { + let entry = deal.entry.to_lowercase(); + if let Some(dt) = Self::parse_datetime(&deal.time) { + if entry.contains("in") && !entry.contains("out") { + open_pos.insert(deal.order.clone(), dt); + } else if entry.contains("out") && deal.profit != 0.0 { + if let Some(in_time) = open_pos.remove(&deal.order) { + let hold_minutes = (dt - in_time).num_seconds() as f64 / 60.0; + if hold_minutes > 0.0 { + hold_times.push((hold_minutes, deal.profit)); + } + } + } + } + } + + if hold_times.is_empty() { + return HoldTimeAnalysis { + avg_hold_minutes: 0.0, + median_hold_minutes: 0.0, + buckets: vec![], + correlation_with_profit: 0.0, + }; + } + + let avg_hold = hold_times.iter().map(|(h, _)| *h).sum::() / hold_times.len() as f64; + let mut sorted_hold: Vec = hold_times.iter().map(|(h, _)| *h).collect(); + sorted_hold.sort_by(|a, b| a.partial_cmp(b).unwrap()); + let median_hold = sorted_hold[sorted_hold.len() / 2]; + + // Calculate correlation + let n = hold_times.len() as f64; + let sum_x = hold_times.iter().map(|(h, _)| *h).sum::(); + let sum_y = hold_times.iter().map(|(_, p)| *p).sum::(); + let sum_xy = hold_times.iter().map(|(h, p)| h * p).sum::(); + let sum_x2 = hold_times.iter().map(|(h, _)| h * h).sum::(); + let sum_y2 = hold_times.iter().map(|(_, p)| p * p).sum::(); + + let correlation = if n > 1.0 { + let numerator = n * sum_xy - sum_x * sum_y; + let denominator = ((n * sum_x2 - sum_x * sum_x) * (n * sum_y2 - sum_y * sum_y)).sqrt(); + if denominator > 0.0 { numerator / denominator } else { 0.0 } + } else { + 0.0 + }; + + // Create buckets + let bucket_defs = [ + (0.0, 15.0, "< 15 min"), + (15.0, 60.0, "15-60 min"), + (60.0, 240.0, "1-4 hours"), + (240.0, 1440.0, "4-24 hours"), + (1440.0, 10080.0, "1-7 days"), + (10080.0, 999999.0, "> 7 days"), + ]; + + let buckets: Vec = bucket_defs + .iter() + .map(|(min, max, range)| { + let bucket_deals: Vec<(f64, f64)> = hold_times + .iter() + .filter(|(h, _)| *h >= *min && *h < *max) + .cloned() + .collect(); + let count = bucket_deals.len() as i32; + let total_pnl: f64 = bucket_deals.iter().map(|(_, p)| *p).sum(); + let avg_profit = if count > 0 { total_pnl / count as f64 } else { 0.0 }; + + HoldTimeBucket { + range: range.to_string(), + min_minutes: *min, + max_minutes: *max, + count, + avg_profit: (avg_profit * 100.0).round() / 100.0, + total_pnl: (total_pnl * 100.0).round() / 100.0, + } + }) + .collect(); + + HoldTimeAnalysis { + avg_hold_minutes: (avg_hold * 10.0).round() / 10.0, + median_hold_minutes: (median_hold * 10.0).round() / 10.0, + buckets, + correlation_with_profit: (correlation * 1000.0).round() / 1000.0, + } + } + + pub fn layer_performance(&self, deals: &[Deal]) -> Vec { + let mut layer_stats: HashMap = HashMap::new(); + + for deal in deals { + let entry = deal.entry.to_lowercase(); + if entry.contains("out") && deal.profit != 0.0 { + let layer = self.extract_layer(&deal.comment); + let stats = layer_stats.entry(layer).or_insert((0, 0, 0.0, 0.0)); + stats.0 += 1; + stats.2 += deal.profit; + stats.3 += deal.volume; + if deal.profit > 0.0 { + stats.1 += 1; + } + } + } + + let mut result: Vec = layer_stats + .into_iter() + .map(|(layer, (trades, wins, total_pnl, total_volume))| LayerPerformance { + layer, + trades, + wins, + total_pnl: (total_pnl * 100.0).round() / 100.0, + win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 }, + avg_volume: if trades > 0 { (total_volume / trades as f64 * 10000.0).round() / 10000.0 } else { 0.0 }, + avg_profit: if trades > 0 { (total_pnl / trades as f64 * 100.0).round() / 100.0 } else { 0.0 }, + }) + .collect(); + + result.sort_by_key(|l| l.layer); + result + } + + pub fn volume_analysis(&self, deals: &[Deal]) -> VolumeAnalysis { + let closed: Vec<&Deal> = deals + .iter() + .filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0) + .collect(); + + if closed.is_empty() { + return VolumeAnalysis { + correlation_with_profit: 0.0, + by_volume_bucket: vec![], + }; + } + + // Calculate correlation + let n = closed.len() as f64; + let sum_x: f64 = closed.iter().map(|d| d.volume).sum(); + let sum_y: f64 = closed.iter().map(|d| d.profit).sum(); + let sum_xy: f64 = closed.iter().map(|d| d.volume * d.profit).sum(); + let sum_x2: f64 = closed.iter().map(|d| d.volume * d.volume).sum(); + let sum_y2: f64 = closed.iter().map(|d| d.profit * d.profit).sum(); + + let correlation = if n > 1.0 { + let numerator = n * sum_xy - sum_x * sum_y; + let denominator = ((n * sum_x2 - sum_x * sum_x) * (n * sum_y2 - sum_y * sum_y)).sqrt(); + if denominator > 0.0 { numerator / denominator } else { 0.0 } + } else { + 0.0 + }; + + // Create volume buckets + let bucket_defs = [ + (0.0, 0.1, "0.0-0.1 lots"), + (0.1, 0.5, "0.1-0.5 lots"), + (0.5, 1.0, "0.5-1.0 lots"), + (1.0, 2.0, "1.0-2.0 lots"), + (2.0, 5.0, "2.0-5.0 lots"), + (5.0, 999.0, "5.0+ lots"), + ]; + + let by_volume_bucket: Vec = bucket_defs + .iter() + .map(|(min, max, range)| { + let bucket_deals: Vec<&Deal> = closed + .iter() + .filter(|d| d.volume >= *min && d.volume < *max) + .cloned() + .collect(); + let trades = bucket_deals.len() as i32; + let total_pnl: f64 = bucket_deals.iter().map(|d| d.profit).sum(); + let avg_profit = if trades > 0 { total_pnl / trades as f64 } else { 0.0 }; + + VolumeBucket { + volume_range: range.to_string(), + min_volume: *min, + max_volume: *max, + trades, + avg_profit: (avg_profit * 100.0).round() / 100.0, + total_pnl: (total_pnl * 100.0).round() / 100.0, + } + }) + .collect(); + + VolumeAnalysis { + correlation_with_profit: (correlation * 1000.0).round() / 1000.0, + by_volume_bucket, + } + } + + pub fn cost_analysis(&self, deals: &[Deal]) -> CostAnalysis { + let total_commission: f64 = deals.iter().map(|d| d.commission.abs()).sum(); + let total_swap: f64 = deals.iter().map(|d| d.swap.abs()).sum(); + let gross_profit: f64 = deals.iter().map(|d| d.profit).filter(|p| *p > 0.0).sum(); + let trade_count = deals.iter().filter(|d| d.entry.to_lowercase().contains("out")).count() as f64; + + let commission_pct = if gross_profit > 0.0 { (total_commission / gross_profit * 10000.0).round() / 100.0 } else { 0.0 }; + let swap_pct = if gross_profit > 0.0 { (total_swap / gross_profit * 10000.0).round() / 100.0 } else { 0.0 }; + + // Calculate what win rate would be without costs + let wins_before_costs = deals + .iter() + .filter(|d| { + let profit_before_costs = d.profit + d.commission.abs() + d.swap.abs(); + d.entry.to_lowercase().contains("out") && profit_before_costs > 0.0 + }) + .count() as f64; + let total_closed = deals.iter().filter(|d| d.entry.to_lowercase().contains("out")).count() as f64; + let win_rate_before_costs = if total_closed > 0.0 { wins_before_costs / total_closed * 100.0 } else { 0.0 }; + + let current_wins = deals.iter().filter(|d| d.entry.to_lowercase().contains("out") && d.profit > 0.0).count() as f64; + let current_win_rate = if total_closed > 0.0 { current_wins / total_closed * 100.0 } else { 0.0 }; + + CostAnalysis { + total_commission: (total_commission * 100.0).round() / 100.0, + total_swap: (total_swap * 100.0).round() / 100.0, + commission_pct_of_profit: commission_pct, + swap_pct_of_profit: swap_pct, + avg_commission_per_trade: if trade_count > 0.0 { (total_commission / trade_count * 100.0).round() / 100.0 } else { 0.0 }, + avg_swap_per_trade: if trade_count > 0.0 { (total_swap / trade_count * 100.0).round() / 100.0 } else { 0.0 }, + net_profit_before_costs: (gross_profit * 100.0).round() / 100.0, + cost_impact_on_win_rate: (win_rate_before_costs - current_win_rate * 100.0).round() / 100.0, + } + } + + pub fn efficiency_analysis(&self, deals: &[Deal], _metrics: &Metrics) -> EfficiencyAnalysis { + let closed: Vec<&Deal> = deals + .iter() + .filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0) + .collect(); + + if closed.is_empty() { + return EfficiencyAnalysis { + profit_per_hour: 0.0, + profit_per_day: 0.0, + profit_per_trade_hour: 0.0, + avg_trade_duration_hours: 0.0, + annualized_return_pct: 0.0, + trades_per_day: 0.0, + }; + } + + let total_profit: f64 = closed.iter().map(|d| d.profit).sum(); + let total_trades = closed.len() as f64; + + // Calculate total hold time + let mut total_hold_minutes = 0.0; + let mut open_pos: HashMap> = HashMap::new(); + + for deal in deals { + let entry = deal.entry.to_lowercase(); + if let Some(dt) = Self::parse_datetime(&deal.time) { + if entry.contains("in") && !entry.contains("out") { + open_pos.insert(deal.order.clone(), dt); + } else if entry.contains("out") && deal.profit != 0.0 { + if let Some(in_time) = open_pos.remove(&deal.order) { + let hold_minutes = (dt - in_time).num_seconds() as f64 / 60.0; + if hold_minutes > 0.0 { + total_hold_minutes += hold_minutes; + } + } + } + } + } + + let total_hold_hours = total_hold_minutes / 60.0; + let avg_trade_duration = if total_trades > 0.0 { total_hold_minutes / total_trades / 60.0 } else { 0.0 }; + + // Get date range + let dates: Vec> = deals + .iter() + .filter_map(|d| Self::parse_datetime(&d.time)) + .collect(); + + let total_days = if dates.len() >= 2 { + let min_date = dates.iter().min().unwrap(); + let max_date = dates.iter().max().unwrap(); + (*max_date - *min_date).num_days().max(1) as f64 + } else { + 1.0 + }; + + // Use a default deposit of 10000 for annualized calculation + // In real scenarios, this should come from the report + let deposit = 10000.0; + let annualized = if total_days > 0.0 && deposit > 0.0 { + let daily_return = total_profit / deposit / total_days; + ((1.0 + daily_return).powf(365.0) - 1.0) * 100.0 + } else { + 0.0 + }; + + EfficiencyAnalysis { + profit_per_hour: if total_hold_hours > 0.0 { (total_profit / total_hold_hours * 100.0).round() / 100.0 } else { 0.0 }, + profit_per_day: (total_profit / total_days * 100.0).round() / 100.0, + profit_per_trade_hour: if total_hold_hours > 0.0 { (total_profit / total_hold_hours / total_trades * 100.0).round() / 100.0 } else { 0.0 }, + avg_trade_duration_hours: (avg_trade_duration * 10.0).round() / 10.0, + annualized_return_pct: (annualized * 10.0).round() / 10.0, + trades_per_day: (total_trades / total_days * 10.0).round() / 10.0, + } + } +} diff --git a/src/storage/database.rs b/src/storage/database.rs index deee39c..78de5d3 100644 --- a/src/storage/database.rs +++ b/src/storage/database.rs @@ -374,4 +374,543 @@ impl ReportDb { Ok(entry) } + + /// Get a specific report by ID + pub fn get_by_id(&self, id: &str) -> Result> { + let conn = self.connect()?; + let mut stmt = conn.prepare( + "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ + created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ + net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ + win_rate_pct, recovery_factor, deposit, currency, leverage, \ + duration_seconds, tags, notes, verdict \ + FROM reports WHERE id = ?" + )?; + + let entry = stmt + .query_map([id], |row| { + let tags_json: String = row.get(23)?; + let tags: Vec = serde_json::from_str(&tags_json).unwrap_or_default(); + Ok(ReportEntry { + id: row.get(0)?, + expert: row.get(1)?, + symbol: row.get(2)?, + timeframe: row.get(3)?, + model: row.get(4)?, + from_date: row.get(5)?, + to_date: row.get(6)?, + created_at: row.get(7)?, + set_file_original: row.get(8)?, + set_snapshot_path: row.get(9)?, + report_dir: row.get(10)?, + charts_dir: row.get(11)?, + net_profit: row.get(12)?, + profit_factor: row.get(13)?, + max_dd_pct: row.get(14)?, + sharpe_ratio: row.get(15)?, + total_trades: row.get(16)?, + win_rate_pct: row.get(17)?, + recovery_factor: row.get(18)?, + deposit: row.get(19)?, + currency: row.get(20)?, + leverage: row.get(21)?, + duration_seconds: row.get(22)?, + tags, + notes: row.get(24)?, + verdict: row.get(25)?, + }) + })? + .filter_map(|r| r.ok()) + .next(); + + Ok(entry) + } + + /// Search reports by tags (at least one tag must match) + pub fn search_by_tags(&self, tags: &[String], limit: usize) -> Result> { + let conn = self.connect()?; + let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ + created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ + net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ + win_rate_pct, recovery_factor, deposit, currency, leverage, \ + duration_seconds, tags, notes, verdict \ + FROM reports WHERE 1=1" + .to_string(); + + // Build OR conditions for tags - use JSON1 extension for tag matching + if !tags.is_empty() { + let tag_conditions: Vec = tags.iter() + .map(|tag| format!("tags LIKE '%{}%'", tag.replace("'", "''"))) + .collect(); + sql.push_str(&format!(" AND ({})", tag_conditions.join(" OR "))); + } + sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit)); + + let mut stmt = conn.prepare(&sql)?; + let entries: Vec = stmt + .query_map([], |row| { + let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); + let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); + Ok(ReportEntry { + id: row.get(0)?, + expert: row.get(1)?, + symbol: row.get(2)?, + timeframe: row.get(3)?, + model: row.get(4)?, + from_date: row.get(5)?, + to_date: row.get(6)?, + created_at: row.get(7)?, + set_file_original: row.get(8)?, + set_snapshot_path: row.get(9)?, + report_dir: row.get(10)?, + charts_dir: row.get(11)?, + net_profit: row.get(12)?, + profit_factor: row.get(13)?, + max_dd_pct: row.get(14)?, + sharpe_ratio: row.get(15)?, + total_trades: row.get(16)?, + win_rate_pct: row.get(17)?, + recovery_factor: row.get(18)?, + deposit: row.get(19)?, + currency: row.get(20)?, + leverage: row.get(21)?, + duration_seconds: row.get(22)?, + tags, + notes: row.get(24)?, + verdict: row.get(25)?, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + + Ok(entries) + } + + /// Search reports by notes text (case-insensitive LIKE) + pub fn search_by_notes(&self, query: &str, limit: usize) -> Result> { + let conn = self.connect()?; + let pattern = format!("%{}%", query.replace("'", "''")); + let mut stmt = conn.prepare( + &format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ + created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ + net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ + win_rate_pct, recovery_factor, deposit, currency, leverage, \ + duration_seconds, tags, notes, verdict \ + FROM reports WHERE notes LIKE '{}' ORDER BY created_at DESC LIMIT {}", + pattern, limit) + )?; + + let entries: Vec = stmt + .query_map([], |row| { + let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); + let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); + Ok(ReportEntry { + id: row.get(0)?, + expert: row.get(1)?, + symbol: row.get(2)?, + timeframe: row.get(3)?, + model: row.get(4)?, + from_date: row.get(5)?, + to_date: row.get(6)?, + created_at: row.get(7)?, + set_file_original: row.get(8)?, + set_snapshot_path: row.get(9)?, + report_dir: row.get(10)?, + charts_dir: row.get(11)?, + net_profit: row.get(12)?, + profit_factor: row.get(13)?, + max_dd_pct: row.get(14)?, + sharpe_ratio: row.get(15)?, + total_trades: row.get(16)?, + win_rate_pct: row.get(17)?, + recovery_factor: row.get(18)?, + deposit: row.get(19)?, + currency: row.get(20)?, + leverage: row.get(21)?, + duration_seconds: row.get(22)?, + tags, + notes: row.get(24)?, + verdict: row.get(25)?, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + + Ok(entries) + } + + /// Find reports by set file (original or snapshot) + pub fn search_by_set_file(&self, set_file: &str, limit: usize) -> Result> { + let conn = self.connect()?; + let pattern = format!("%{}%", set_file.replace("'", "''")); + let mut stmt = conn.prepare( + &format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ + created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ + net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ + win_rate_pct, recovery_factor, deposit, currency, leverage, \ + duration_seconds, tags, notes, verdict \ + FROM reports WHERE (set_file_original LIKE '{}' OR set_snapshot_path LIKE '{}') \ + ORDER BY created_at DESC LIMIT {}", + pattern, pattern, limit) + )?; + + let entries: Vec = stmt + .query_map([], |row| { + let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); + let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); + Ok(ReportEntry { + id: row.get(0)?, + expert: row.get(1)?, + symbol: row.get(2)?, + timeframe: row.get(3)?, + model: row.get(4)?, + from_date: row.get(5)?, + to_date: row.get(6)?, + created_at: row.get(7)?, + set_file_original: row.get(8)?, + set_snapshot_path: row.get(9)?, + report_dir: row.get(10)?, + charts_dir: row.get(11)?, + net_profit: row.get(12)?, + profit_factor: row.get(13)?, + max_dd_pct: row.get(14)?, + sharpe_ratio: row.get(15)?, + total_trades: row.get(16)?, + win_rate_pct: row.get(17)?, + recovery_factor: row.get(18)?, + deposit: row.get(19)?, + currency: row.get(20)?, + leverage: row.get(21)?, + duration_seconds: row.get(22)?, + tags, + notes: row.get(24)?, + verdict: row.get(25)?, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + + Ok(entries) + } + + /// Search by backtest date range (from_date and to_date fields) + pub fn search_by_date_range( + &self, + from_start: Option<&str>, + from_end: Option<&str>, + to_start: Option<&str>, + to_end: Option<&str>, + limit: usize, + ) -> Result> { + let conn = self.connect()?; + let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ + created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ + net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ + win_rate_pct, recovery_factor, deposit, currency, leverage, \ + duration_seconds, tags, notes, verdict \ + FROM reports WHERE 1=1" + .to_string(); + + let mut params: Vec = Vec::new(); + + if let Some(start) = from_start { + sql.push_str(" AND from_date >= ?"); + params.push(start.to_string()); + } + if let Some(end) = from_end { + sql.push_str(" AND from_date <= ?"); + params.push(end.to_string()); + } + if let Some(start) = to_start { + sql.push_str(" AND to_date >= ?"); + params.push(start.to_string()); + } + if let Some(end) = to_end { + sql.push_str(" AND to_date <= ?"); + params.push(end.to_string()); + } + + sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit)); + + let mut stmt = conn.prepare(&sql)?; + let entries: Vec = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |row| { + let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); + let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); + Ok(ReportEntry { + id: row.get(0)?, + expert: row.get(1)?, + symbol: row.get(2)?, + timeframe: row.get(3)?, + model: row.get(4)?, + from_date: row.get(5)?, + to_date: row.get(6)?, + created_at: row.get(7)?, + set_file_original: row.get(8)?, + set_snapshot_path: row.get(9)?, + report_dir: row.get(10)?, + charts_dir: row.get(11)?, + net_profit: row.get(12)?, + profit_factor: row.get(13)?, + max_dd_pct: row.get(14)?, + sharpe_ratio: row.get(15)?, + total_trades: row.get(16)?, + win_rate_pct: row.get(17)?, + recovery_factor: row.get(18)?, + deposit: row.get(19)?, + currency: row.get(20)?, + leverage: row.get(21)?, + duration_seconds: row.get(22)?, + tags, + notes: row.get(24)?, + verdict: row.get(25)?, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + + Ok(entries) + } + + /// Get comparable reports (same expert/symbol/timeframe for comparison) + pub fn get_comparable( + &self, + expert: &str, + symbol: &str, + timeframe: &str, + exclude_id: Option<&str>, + limit: usize, + ) -> Result> { + let conn = self.connect()?; + let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ + created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ + net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ + win_rate_pct, recovery_factor, deposit, currency, leverage, \ + duration_seconds, tags, notes, verdict \ + FROM reports WHERE expert = ? AND symbol = ? AND timeframe = ?" + .to_string(); + + let mut params: Vec = vec![ + expert.to_string(), + symbol.to_string(), + timeframe.to_string(), + ]; + + if let Some(id) = exclude_id { + sql.push_str(" AND id != ?"); + params.push(id.to_string()); + } + + sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit)); + + let mut stmt = conn.prepare(&sql)?; + let entries: Vec = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |row| { + let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); + let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); + Ok(ReportEntry { + id: row.get(0)?, + expert: row.get(1)?, + symbol: row.get(2)?, + timeframe: row.get(3)?, + model: row.get(4)?, + from_date: row.get(5)?, + to_date: row.get(6)?, + created_at: row.get(7)?, + set_file_original: row.get(8)?, + set_snapshot_path: row.get(9)?, + report_dir: row.get(10)?, + charts_dir: row.get(11)?, + net_profit: row.get(12)?, + profit_factor: row.get(13)?, + max_dd_pct: row.get(14)?, + sharpe_ratio: row.get(15)?, + total_trades: row.get(16)?, + win_rate_pct: row.get(17)?, + recovery_factor: row.get(18)?, + deposit: row.get(19)?, + currency: row.get(20)?, + leverage: row.get(21)?, + duration_seconds: row.get(22)?, + tags, + notes: row.get(24)?, + verdict: row.get(25)?, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + + Ok(entries) + } + + /// Get reports sorted by a specific metric (for best/worst queries) + pub fn get_sorted_by( + &self, + sort_column: &str, + ascending: bool, + limit: usize, + filters: &ReportFilters, + ) -> Result> { + let conn = self.connect()?; + + // Validate sort column to prevent SQL injection + let valid_columns = ["net_profit", "profit_factor", "max_dd_pct", "win_rate_pct", + "sharpe_ratio", "recovery_factor", "total_trades", "created_at"]; + if !valid_columns.contains(&sort_column) { + return Err(anyhow::anyhow!("Invalid sort column: {}", sort_column)); + } + + let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \ + created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \ + net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \ + win_rate_pct, recovery_factor, deposit, currency, leverage, \ + duration_seconds, tags, notes, verdict \ + FROM reports WHERE 1=1" + .to_string(); + + let mut filter_params: Vec = Vec::new(); + + if let Some(ea) = &filters.expert { + sql.push_str(" AND expert LIKE ?"); + filter_params.push(format!("%{}%", ea)); + } + if let Some(sym) = &filters.symbol { + sql.push_str(" AND symbol = ?"); + filter_params.push(sym.clone()); + } + if let Some(tf) = &filters.timeframe { + sql.push_str(" AND timeframe = ?"); + filter_params.push(tf.clone()); + } + if let Some(verdict) = &filters.verdict { + sql.push_str(" AND verdict = ?"); + filter_params.push(verdict.clone()); + } + + // For non-created_at columns, only include rows where that column is not NULL + if sort_column != "created_at" { + sql.push_str(&format!(" AND {} IS NOT NULL", sort_column)); + } + + let order = if ascending { "ASC" } else { "DESC" }; + sql.push_str(&format!(" ORDER BY {} {} LIMIT {}", sort_column, order, limit)); + + let mut stmt = conn.prepare(&sql)?; + let entries: Vec = stmt + .query_map(rusqlite::params_from_iter(filter_params.iter()), |row| { + let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string()); + let tags: Vec = serde_json::from_str(&tags_str).unwrap_or_default(); + Ok(ReportEntry { + id: row.get(0)?, + expert: row.get(1)?, + symbol: row.get(2)?, + timeframe: row.get(3)?, + model: row.get(4)?, + from_date: row.get(5)?, + to_date: row.get(6)?, + created_at: row.get(7)?, + set_file_original: row.get(8)?, + set_snapshot_path: row.get(9)?, + report_dir: row.get(10)?, + charts_dir: row.get(11)?, + net_profit: row.get(12)?, + profit_factor: row.get(13)?, + max_dd_pct: row.get(14)?, + sharpe_ratio: row.get(15)?, + total_trades: row.get(16)?, + win_rate_pct: row.get(17)?, + recovery_factor: row.get(18)?, + deposit: row.get(19)?, + currency: row.get(20)?, + leverage: row.get(21)?, + duration_seconds: row.get(22)?, + tags, + notes: row.get(24)?, + verdict: row.get(25)?, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + + Ok(entries) + } + + /// Get aggregate statistics for reports + pub fn get_stats(&self, filters: &ReportFilters) -> Result { + let conn = self.connect()?; + + let mut sql = "SELECT \ + COUNT(*), \ + AVG(net_profit), \ + AVG(profit_factor), \ + AVG(max_dd_pct), \ + AVG(win_rate_pct), \ + AVG(sharpe_ratio), \ + SUM(CASE WHEN net_profit > 0 THEN 1 ELSE 0 END), \ + SUM(CASE WHEN verdict = 'pass' THEN 1 ELSE 0 END), \ + SUM(CASE WHEN verdict = 'fail' THEN 1 ELSE 0 END), \ + SUM(CASE WHEN verdict = 'marginal' THEN 1 ELSE 0 END) \ + FROM reports WHERE 1=1" + .to_string(); + + let mut filter_params: Vec = Vec::new(); + + if let Some(ea) = &filters.expert { + sql.push_str(" AND expert LIKE ?"); + filter_params.push(format!("%{}%", ea)); + } + if let Some(sym) = &filters.symbol { + sql.push_str(" AND symbol = ?"); + filter_params.push(sym.clone()); + } + if let Some(tf) = &filters.timeframe { + sql.push_str(" AND timeframe = ?"); + filter_params.push(tf.clone()); + } + if let Some(verdict) = &filters.verdict { + sql.push_str(" AND verdict = ?"); + filter_params.push(verdict.clone()); + } + + let stats: ReportStats = conn.query_row( + &sql, + rusqlite::params_from_iter(filter_params.iter()), + |row| { + let total: i64 = row.get(0)?; + let profitable: Option = row.get(6)?; + let pass_count: Option = row.get(7)?; + let fail_count: Option = row.get(8)?; + let marginal_count: Option = row.get(9)?; + + Ok(ReportStats { + total_count: total as usize, + avg_net_profit: row.get(1)?, + avg_profit_factor: row.get(2)?, + avg_max_dd_pct: row.get(3)?, + avg_win_rate_pct: row.get(4)?, + avg_sharpe_ratio: row.get(5)?, + profitable_count: profitable.unwrap_or(0) as usize, + pass_verdict_count: pass_count.unwrap_or(0) as usize, + fail_verdict_count: fail_count.unwrap_or(0) as usize, + marginal_verdict_count: marginal_count.unwrap_or(0) as usize, + }) + }, + )?; + + Ok(stats) + } +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ReportStats { + pub total_count: usize, + pub avg_net_profit: Option, + pub avg_profit_factor: Option, + pub avg_max_dd_pct: Option, + pub avg_win_rate_pct: Option, + pub avg_sharpe_ratio: Option, + pub profitable_count: usize, + pub pass_verdict_count: usize, + pub fail_verdict_count: usize, + pub marginal_verdict_count: usize, } diff --git a/src/tools/definitions/analytics.rs b/src/tools/definitions/analytics.rs index 05bb24c..ecedcb8 100644 --- a/src/tools/definitions/analytics.rs +++ b/src/tools/definitions/analytics.rs @@ -126,3 +126,155 @@ pub fn tool_analyze_concurrent_peak() -> Value { } }) } + +pub fn tool_list_deals() -> Value { + json!({ + "name": "list_deals", + "description": "List individual deals from a backtest report with optional filters", + "inputSchema": { + "type": "object", + "required": ["report_dir"], + "properties": { + "report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" }, + "deal_type": { "type": "string", "enum": ["buy", "sell"], "description": "Filter by deal type" }, + "min_profit": { "type": "number", "description": "Minimum profit (use negative for losses)" }, + "max_profit": { "type": "number", "description": "Maximum profit" }, + "start_date": { "type": "string", "description": "Start date filter (YYYY.MM.DD)" }, + "end_date": { "type": "string", "description": "End date filter (YYYY.MM.DD)" }, + "min_volume": { "type": "number", "description": "Minimum volume/lots" }, + "max_volume": { "type": "number", "description": "Maximum volume/lots" }, + "limit": { "type": "integer", "default": 100, "description": "Max deals to return" } + } + } + }) +} + +pub fn tool_search_deals_by_comment() -> Value { + json!({ + "name": "search_deals_by_comment", + "description": "Search deals by comment text (case-insensitive partial match)", + "inputSchema": { + "type": "object", + "required": ["report_dir", "query"], + "properties": { + "report_dir": { "type": "string" }, + "query": { "type": "string", "description": "Search text in comments" }, + "limit": { "type": "integer", "default": 50 } + } + } + }) +} + +pub fn tool_search_deals_by_magic() -> Value { + json!({ + "name": "search_deals_by_magic", + "description": "Filter deals by magic number (EA identifier)", + "inputSchema": { + "type": "object", + "required": ["report_dir", "magic"], + "properties": { + "report_dir": { "type": "string" }, + "magic": { "type": "string", "description": "Magic number to filter by" }, + "limit": { "type": "integer", "default": 100 } + } + } + }) +} + +pub fn tool_analyze_profit_distribution() -> Value { + json!({ + "name": "analyze_profit_distribution", + "description": "Analyze profit distribution - small/medium/large wins and losses with detailed buckets", + "inputSchema": { + "type": "object", + "required": ["report_dir"], + "properties": { + "report_dir": { "type": "string" } + } + } + }) +} + +pub fn tool_analyze_time_performance() -> Value { + json!({ + "name": "analyze_time_performance", + "description": "Analyze performance by hour of day and day of week", + "inputSchema": { + "type": "object", + "required": ["report_dir"], + "properties": { + "report_dir": { "type": "string" } + } + } + }) +} + +pub fn tool_analyze_hold_time_distribution() -> Value { + json!({ + "name": "analyze_hold_time_distribution", + "description": "Analyze hold time distribution and correlation with profit", + "inputSchema": { + "type": "object", + "required": ["report_dir"], + "properties": { + "report_dir": { "type": "string" } + } + } + }) +} + +pub fn tool_analyze_layer_performance() -> Value { + json!({ + "name": "analyze_layer_performance", + "description": "Analyze performance by grid layer (extracted from deal comments)", + "inputSchema": { + "type": "object", + "required": ["report_dir"], + "properties": { + "report_dir": { "type": "string" } + } + } + }) +} + +pub fn tool_analyze_volume_vs_profit() -> Value { + json!({ + "name": "analyze_volume_vs_profit", + "description": "Analyze correlation between volume and profit, plus performance by volume bucket", + "inputSchema": { + "type": "object", + "required": ["report_dir"], + "properties": { + "report_dir": { "type": "string" } + } + } + }) +} + +pub fn tool_analyze_costs() -> Value { + json!({ + "name": "analyze_costs", + "description": "Analyze commission and swap costs impact on profitability", + "inputSchema": { + "type": "object", + "required": ["report_dir"], + "properties": { + "report_dir": { "type": "string" } + } + } + }) +} + +pub fn tool_analyze_efficiency() -> Value { + json!({ + "name": "analyze_efficiency", + "description": "Calculate efficiency metrics: profit per hour/day, annualized return, trade frequency", + "inputSchema": { + "type": "object", + "required": ["report_dir"], + "properties": { + "report_dir": { "type": "string" } + } + } + }) +} diff --git a/src/tools/definitions/mod.rs b/src/tools/definitions/mod.rs index e782eeb..b861b91 100644 --- a/src/tools/definitions/mod.rs +++ b/src/tools/definitions/mod.rs @@ -22,7 +22,7 @@ pub fn get_tools_list() -> Value { optimization::tool_get_optimization_status(), optimization::tool_get_optimization_results(), optimization::tool_list_jobs(), - // Analytics (9 tools) + // Analytics (19 tools) analytics::tool_analyze_report(), analytics::tool_analyze_monthly_pnl(), analytics::tool_analyze_drawdown_events(), @@ -32,6 +32,17 @@ pub fn get_tools_list() -> Value { analytics::tool_analyze_direction_bias(), analytics::tool_analyze_streaks(), analytics::tool_analyze_concurrent_peak(), + // Deal query tools (10) + analytics::tool_list_deals(), + analytics::tool_search_deals_by_comment(), + analytics::tool_search_deals_by_magic(), + analytics::tool_analyze_profit_distribution(), + analytics::tool_analyze_time_performance(), + analytics::tool_analyze_hold_time_distribution(), + analytics::tool_analyze_layer_performance(), + analytics::tool_analyze_volume_vs_profit(), + analytics::tool_analyze_costs(), + analytics::tool_analyze_efficiency(), // Baseline baseline::tool_compare_baseline(), // Experts @@ -58,6 +69,16 @@ pub fn get_tools_list() -> Value { utility::tool_check_mt5_status(), utility::tool_create_set_template(), utility::tool_export_report(), + // Debugging/Diagnostics (10 tools) + utility::tool_diagnose_wine(), + utility::tool_get_mt5_logs(), + utility::tool_search_mt5_errors(), + utility::tool_check_mt5_process(), + utility::tool_kill_mt5_process(), + utility::tool_check_system_resources(), + utility::tool_validate_mt5_config(), + utility::tool_get_wine_prefix_info(), + utility::tool_get_backtest_crash_info(), // Set Files setfiles::tool_read_set_file(), setfiles::tool_write_set_file(), @@ -67,7 +88,7 @@ pub fn get_tools_list() -> Value { setfiles::tool_describe_sweep(), setfiles::tool_list_set_files(), setfiles::tool_set_from_optimization(), - // Reports (11 tools) + // Reports (19 tools) reports::tool_list_reports(), reports::tool_search_reports(), reports::tool_get_latest_report(), @@ -78,6 +99,14 @@ pub fn get_tools_list() -> Value { reports::tool_get_history(), reports::tool_promote_to_baseline(), reports::tool_annotate_history(), + reports::tool_get_report_by_id(), + reports::tool_get_reports_summary(), + reports::tool_get_best_reports(), + reports::tool_search_reports_by_tags(), + reports::tool_search_reports_by_date_range(), + reports::tool_search_reports_by_notes(), + reports::tool_get_reports_by_set_file(), + reports::tool_get_comparable_reports(), ]; serde_json::json!(tools) diff --git a/src/tools/definitions/reports.rs b/src/tools/definitions/reports.rs index 7347d6e..d0c2f72 100644 --- a/src/tools/definitions/reports.rs +++ b/src/tools/definitions/reports.rs @@ -148,3 +148,133 @@ pub fn tool_prune_reports() -> Value { } }) } + +pub fn tool_get_report_by_id() -> Value { + json!({ + "name": "get_report_by_id", + "description": "Get a specific report by its ID with full details and optional equity chart", + "inputSchema": { + "type": "object", + "required": ["id"], + "properties": { + "id": { "type": "string", "description": "Report ID (from list_reports or search_reports)" }, + "include_chart": { "type": "boolean", "description": "Include equity chart as base64 PNG (default: true)", "default": true } + } + } + }) +} + +pub fn tool_get_reports_summary() -> Value { + json!({ + "name": "get_reports_summary", + "description": "Get aggregate statistics across reports - counts, averages by EA/symbol/timeframe/verdict", + "inputSchema": { + "type": "object", + "properties": { + "expert": { "type": "string", "description": "Filter by EA name substring" }, + "symbol": { "type": "string", "description": "Filter by exact symbol" }, + "timeframe": { "type": "string", "description": "Filter by exact timeframe" }, + "verdict": { "type": "string", "description": "Filter by verdict (pass/fail/marginal)" } + } + } + }) +} + +pub fn tool_get_best_reports() -> Value { + json!({ + "name": "get_best_reports", + "description": "Get top N reports sorted by performance metric (profit factor, win rate, drawdown, etc.)", + "inputSchema": { + "type": "object", + "properties": { + "sort_by": { "type": "string", "enum": ["net_profit", "profit_factor", "max_dd_pct", "win_rate_pct", "sharpe_ratio", "recovery_factor", "total_trades"], "default": "profit_factor", "description": "Metric to sort by" }, + "order": { "type": "string", "enum": ["asc", "desc"], "default": "desc", "description": "Sort order (use 'asc' for drawdown, 'desc' for profit)" }, + "limit": { "type": "integer", "default": 10, "description": "Number of reports to return" }, + "expert": { "type": "string", "description": "Filter by EA name substring" }, + "symbol": { "type": "string", "description": "Filter by exact symbol" }, + "timeframe": { "type": "string", "description": "Filter by exact timeframe" }, + "verdict": { "type": "string", "description": "Filter by verdict" } + } + } + }) +} + +pub fn tool_search_reports_by_tags() -> Value { + json!({ + "name": "search_reports_by_tags", + "description": "Search reports by tags - at least one tag must match (OR logic)", + "inputSchema": { + "type": "object", + "required": ["tags"], + "properties": { + "tags": { "type": "array", "items": { "type": "string" }, "description": "Tags to search for (e.g., ['production', 'verified'])" }, + "limit": { "type": "integer", "default": 50 } + } + } + }) +} + +pub fn tool_search_reports_by_date_range() -> Value { + json!({ + "name": "search_reports_by_date_range", + "description": "Search reports by backtest date range (from_date and to_date fields)", + "inputSchema": { + "type": "object", + "properties": { + "from_start": { "type": "string", "description": "From date >= this (YYYY.MM.DD)" }, + "from_end": { "type": "string", "description": "From date <= this (YYYY.MM.DD)" }, + "to_start": { "type": "string", "description": "To date >= this (YYYY.MM.DD)" }, + "to_end": { "type": "string", "description": "To date <= this (YYYY.MM.DD)" }, + "limit": { "type": "integer", "default": 50 } + } + } + }) +} + +pub fn tool_search_reports_by_notes() -> Value { + json!({ + "name": "search_reports_by_notes", + "description": "Full-text search in report notes field (case-insensitive LIKE search)", + "inputSchema": { + "type": "object", + "required": ["query"], + "properties": { + "query": { "type": "string", "description": "Search text (partial match)" }, + "limit": { "type": "integer", "default": 50 } + } + } + }) +} + +pub fn tool_get_reports_by_set_file() -> Value { + json!({ + "name": "get_reports_by_set_file", + "description": "Find all reports that used a specific .set parameter file", + "inputSchema": { + "type": "object", + "required": ["set_file"], + "properties": { + "set_file": { "type": "string", "description": "Set filename or partial path to match" }, + "limit": { "type": "integer", "default": 50 } + } + } + }) +} + +pub fn tool_get_comparable_reports() -> Value { + json!({ + "name": "get_comparable_reports", + "description": "Find reports comparable to a given report (same EA, symbol, timeframe) - useful for before/after analysis", + "inputSchema": { + "type": "object", + "properties": { + "report_id": { "type": "string", "description": "Reference report ID (if provided, uses its expert/symbol/timeframe)" }, + "expert": { "type": "string", "description": "EA name (required if report_id not provided)" }, + "symbol": { "type": "string", "description": "Symbol (required if report_id not provided)" }, + "timeframe": { "type": "string", "description": "Timeframe (required if report_id not provided)" }, + "exclude_id": { "type": "string", "description": "Exclude this report ID from results" }, + "limit": { "type": "integer", "default": 20 } + } + } + }) +} diff --git a/src/tools/definitions/utility.rs b/src/tools/definitions/utility.rs index 8a3123d..e1882af 100644 --- a/src/tools/definitions/utility.rs +++ b/src/tools/definitions/utility.rs @@ -165,3 +165,149 @@ pub fn tool_export_report() -> Value { } }) } + +pub fn tool_diagnose_wine() -> Value { + json!({ + "name": "diagnose_wine", + "description": "Check Wine installation, version, and prefix health. Reports errors, warnings, and recent Wine errors.", + "inputSchema": { + "type": "object" + } + }) +} + +pub fn tool_get_mt5_logs() -> Value { + json!({ + "name": "get_mt5_logs", + "description": "Get MT5 terminal, tester, or MetaEditor logs with optional search filtering", + "inputSchema": { + "type": "object", + "properties": { + "log_type": { + "type": "string", + "enum": ["terminal", "tester", "metaeditor"], + "default": "terminal", + "description": "Type of log to retrieve" + }, + "lines": { + "type": "integer", + "default": 100, + "description": "Number of lines to return (from end of log)" + }, + "search": { + "type": "string", + "description": "Optional search term to filter log lines" + } + } + } + }) +} + +pub fn tool_search_mt5_errors() -> Value { + json!({ + "name": "search_mt5_errors", + "description": "Search MT5 logs for error patterns (error, failed, crash, exception, etc.) in recent hours", + "inputSchema": { + "type": "object", + "properties": { + "hours_back": { + "type": "integer", + "default": 24, + "description": "Hours to search back in logs" + }, + "max_errors": { + "type": "integer", + "default": 50, + "description": "Maximum number of errors to return" + } + } + } + }) +} + +pub fn tool_check_mt5_process() -> Value { + json!({ + "name": "check_mt5_process", + "description": "Check if MT5 processes are running, get process info (PID, CPU, memory usage)", + "inputSchema": { + "type": "object" + } + }) +} + +pub fn tool_kill_mt5_process() -> Value { + json!({ + "name": "kill_mt5_process", + "description": "Kill stuck MT5 processes. Use force=true for stuck wineserver.", + "inputSchema": { + "type": "object", + "properties": { + "pid": { + "type": "string", + "description": "Optional specific PID to kill" + }, + "force": { + "type": "boolean", + "default": false, + "description": "Use SIGKILL (-9) instead of SIGTERM (-15), also kills wineserver" + } + } + } + }) +} + +pub fn tool_check_system_resources() -> Value { + json!({ + "name": "check_system_resources", + "description": "Check disk space, memory, and CPU. Warns if resources are low for MT5 operations.", + "inputSchema": { + "type": "object" + } + }) +} + +pub fn tool_validate_mt5_config() -> Value { + json!({ + "name": "validate_mt5_config", + "description": "Validate MT5 configuration files (terminal.ini, tester settings). Reports errors and warnings.", + "inputSchema": { + "type": "object" + } + }) +} + +pub fn tool_get_wine_prefix_info() -> Value { + json!({ + "name": "get_wine_prefix_info", + "description": "Get detailed Wine prefix information: Windows version, installed programs, registry files, drive_c size", + "inputSchema": { + "type": "object" + } + }) +} + +pub fn tool_get_backtest_crash_info() -> Value { + json!({ + "name": "get_backtest_crash_info", + "description": "Investigate backtest crashes/failures. Checks for incomplete markers, missing deals.csv, error logs. Can scan recent reports.", + "inputSchema": { + "type": "object", + "properties": { + "report_dir": { + "type": "string", + "description": "Optional specific report directory to check" + }, + "check_recent": { + "type": "boolean", + "default": true, + "description": "Also check recent reports for failures" + }, + "hours_back": { + "type": "integer", + "default": 6, + "description": "Hours back to check for recent failures" + } + } + } + }) +} diff --git a/src/tools/handlers/analysis.rs b/src/tools/handlers/analysis.rs index 730769e..342fbc6 100644 --- a/src/tools/handlers/analysis.rs +++ b/src/tools/handlers/analysis.rs @@ -305,5 +305,336 @@ pub async fn handle_analyze_concurrent_peak(_config: &Config, args: &Value) -> R })) } +// === Deal Query Handlers === + +pub async fn handle_list_deals(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + + // Apply filters + let deal_type = args.get("deal_type").and_then(|v| v.as_str()); + let min_profit = args.get("min_profit").and_then(|v| v.as_f64()); + let max_profit = args.get("max_profit").and_then(|v| v.as_f64()); + let start_date = args.get("start_date").and_then(|v| v.as_str()); + let end_date = args.get("end_date").and_then(|v| v.as_str()); + let min_volume = args.get("min_volume").and_then(|v| v.as_f64()); + let max_volume = args.get("max_volume").and_then(|v| v.as_f64()); + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize; + + let mut filtered: Vec<&Deal> = deals.iter().filter(|d| { + // Only include closed trades with non-zero profit + if !d.entry.to_lowercase().contains("out") || d.profit == 0.0 { + return false; + } + + if let Some(dt) = deal_type { + if !d.deal_type.to_lowercase().contains(dt) { + return false; + } + } + + if let Some(min) = min_profit { + if d.profit < min { + return false; + } + } + + if let Some(max) = max_profit { + if d.profit > max { + return false; + } + } + + if let Some(start) = start_date { + if !d.time.starts_with(start) && d.time < start.to_string() { + return false; + } + } + + if let Some(end) = end_date { + if d.time > end.to_string() { + return false; + } + } + + if let Some(min) = min_volume { + if d.volume < min { + return false; + } + } + + if let Some(max) = max_volume { + if d.volume > max { + return false; + } + } + + true + }).collect(); + + // Sort by time descending + filtered.sort_by(|a, b| b.time.cmp(&a.time)); + filtered.truncate(limit); + + let deal_list: Vec = filtered + .iter() + .map(|d| json!({ + "time": d.time, + "deal": d.deal, + "symbol": d.symbol, + "deal_type": d.deal_type, + "volume": d.volume, + "price": d.price, + "profit": d.profit, + "commission": d.commission, + "swap": d.swap, + "comment": d.comment, + "magic": d.magic, + })) + .collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "total_deals": deals.len(), + "filtered_count": deal_list.len(), + "deals": deal_list, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_search_deals_by_comment(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let query = args.get("query") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("query is required"))?; + + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; + + let (deals, _) = load_report_data(report_dir)?; + + let query_lower = query.to_lowercase(); + let mut filtered: Vec<&Deal> = deals + .iter() + .filter(|d| { + d.entry.to_lowercase().contains("out") + && d.profit != 0.0 + && d.comment.to_lowercase().contains(&query_lower) + }) + .collect(); + + filtered.sort_by(|a, b| b.time.cmp(&a.time)); + filtered.truncate(limit); + + let deal_list: Vec = filtered + .iter() + .map(|d| json!({ + "time": d.time, + "deal": d.deal, + "symbol": d.symbol, + "deal_type": d.deal_type, + "volume": d.volume, + "profit": d.profit, + "comment": d.comment, + "magic": d.magic, + })) + .collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "query": query, + "matched": deal_list.len(), + "deals": deal_list, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_search_deals_by_magic(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let magic = args.get("magic") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("magic is required"))?; + + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize; + + let (deals, _) = load_report_data(report_dir)?; + + let mut filtered: Vec<&Deal> = deals + .iter() + .filter(|d| { + d.entry.to_lowercase().contains("out") + && d.profit != 0.0 + && d.magic.as_ref().map(|m| m.contains(magic)).unwrap_or(false) + }) + .collect(); + + filtered.sort_by(|a, b| b.time.cmp(&a.time)); + filtered.truncate(limit); + + let deal_list: Vec = filtered + .iter() + .map(|d| json!({ + "time": d.time, + "deal": d.deal, + "symbol": d.symbol, + "deal_type": d.deal_type, + "volume": d.volume, + "profit": d.profit, + "comment": d.comment, + "magic": d.magic, + })) + .collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "magic": magic, + "matched": deal_list.len(), + "deals": deal_list, + }).to_string() }], + "isError": false + })) +} + +// === New Analytics Handlers === + +pub async fn handle_analyze_profit_distribution(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.profit_distribution(&deals); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "profit_distribution": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_time_performance(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.time_performance(&deals); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "time_performance": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_hold_time_distribution(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.hold_time_analysis(&deals); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "hold_time_analysis": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_layer_performance(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.layer_performance(&deals); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "layer_performance": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_volume_vs_profit(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.volume_analysis(&deals); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "volume_analysis": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_costs(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, _) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.cost_analysis(&deals); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "cost_analysis": result, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_analyze_efficiency(_config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; + + let (deals, metrics) = load_report_data(report_dir)?; + let analyzer = DealAnalyzer::new(); + let result = analyzer.efficiency_analysis(&deals, &metrics); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": true, + "efficiency_analysis": result, + }).to_string() }], + "isError": false + })) +} + // Import Config for analysis module use crate::models::Config; diff --git a/src/tools/handlers/mod.rs b/src/tools/handlers/mod.rs index 336b2d5..8a79a78 100644 --- a/src/tools/handlers/mod.rs +++ b/src/tools/handlers/mod.rs @@ -65,6 +65,17 @@ impl ToolHandler { "analyze_streaks" => analysis::handle_analyze_streaks(&self.config, args).await, "analyze_concurrent_peak" => analysis::handle_analyze_concurrent_peak(&self.config, args).await, "compare_baseline" => analysis::handle_compare_baseline(&self.config, args).await, + // Deal query handlers + "list_deals" => analysis::handle_list_deals(&self.config, args).await, + "search_deals_by_comment" => analysis::handle_search_deals_by_comment(&self.config, args).await, + "search_deals_by_magic" => analysis::handle_search_deals_by_magic(&self.config, args).await, + "analyze_profit_distribution" => analysis::handle_analyze_profit_distribution(&self.config, args).await, + "analyze_time_performance" => analysis::handle_analyze_time_performance(&self.config, args).await, + "analyze_hold_time_distribution" => analysis::handle_analyze_hold_time_distribution(&self.config, args).await, + "analyze_layer_performance" => analysis::handle_analyze_layer_performance(&self.config, args).await, + "analyze_volume_vs_profit" => analysis::handle_analyze_volume_vs_profit(&self.config, args).await, + "analyze_costs" => analysis::handle_analyze_costs(&self.config, args).await, + "analyze_efficiency" => analysis::handle_analyze_efficiency(&self.config, args).await, // Set file handlers "read_set_file" => setfiles::handle_read_set_file(args).await, @@ -87,6 +98,14 @@ impl ToolHandler { "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, + "get_report_by_id" => reports::handle_get_report_by_id(&self.config, args).await, + "get_reports_summary" => reports::handle_get_reports_summary(args).await, + "get_best_reports" => reports::handle_get_best_reports(args).await, + "search_reports_by_tags" => reports::handle_search_reports_by_tags(args).await, + "search_reports_by_date_range" => reports::handle_search_reports_by_date_range(args).await, + "search_reports_by_notes" => reports::handle_search_reports_by_notes(args).await, + "get_reports_by_set_file" => reports::handle_get_reports_by_set_file(args).await, + "get_comparable_reports" => reports::handle_get_comparable_reports(args).await, // Utility handlers "check_symbol_data_status" => utility::handle_check_symbol_data_status(&self.config, args).await, @@ -97,7 +116,17 @@ impl ToolHandler { "check_mt5_status" => utility::handle_check_mt5_status(&self.config).await, "create_set_template" => utility::handle_create_set_template(&self.config, args).await, "export_report" => utility::handle_export_report(&self.config, args).await, - + // Debugging/diagnostics handlers + "diagnose_wine" => utility::handle_diagnose_wine(&self.config, args).await, + "get_mt5_logs" => utility::handle_get_mt5_logs(&self.config, args).await, + "search_mt5_errors" => utility::handle_search_mt5_errors(&self.config, args).await, + "check_mt5_process" => utility::handle_check_mt5_process(&self.config, args).await, + "kill_mt5_process" => utility::handle_kill_mt5_process(&self.config, args).await, + "check_system_resources" => utility::handle_check_system_resources(&self.config, args).await, + "validate_mt5_config" => utility::handle_validate_mt5_config(&self.config, args).await, + "get_wine_prefix_info" => utility::handle_get_wine_prefix_info(&self.config, args).await, + "get_backtest_crash_info" => utility::handle_get_backtest_crash_info(&self.config, args).await, + _ => Ok(json!({ "content": [{ "type": "text", "text": format!("Tool '{}' not implemented", name) }], "isError": true diff --git a/src/tools/handlers/reports.rs b/src/tools/handlers/reports.rs index e2a0b75..7d4fcc0 100644 --- a/src/tools/handlers/reports.rs +++ b/src/tools/handlers/reports.rs @@ -433,3 +433,418 @@ pub async fn handle_annotate_history(args: &Value) -> Result { "isError": false })) } + +pub async fn handle_get_report_by_id(_config: &Config, args: &Value) -> Result { + let id = args.get("id") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("id is required"))?; + let include_chart = args.get("include_chart").and_then(|v| v.as_bool()).unwrap_or(true); + + let db = ReportDb::new(&Config::db_path()); + db.init()?; + + match db.get_by_id(id)? { + Some(entry) => { + let mut response = json!({ + "success": true, + "report": { + "id": entry.id, + "expert": entry.expert, + "symbol": entry.symbol, + "timeframe": entry.timeframe, + "from_date": entry.from_date, + "to_date": entry.to_date, + "created_at": entry.created_at, + "net_profit": entry.net_profit, + "profit_factor": entry.profit_factor, + "max_dd_pct": entry.max_dd_pct, + "sharpe_ratio": entry.sharpe_ratio, + "total_trades": entry.total_trades, + "win_rate_pct": entry.win_rate_pct, + "recovery_factor": entry.recovery_factor, + "deposit": entry.deposit, + "currency": entry.currency, + "leverage": entry.leverage, + "duration_seconds": entry.duration_seconds, + "set_file_original": entry.set_file_original, + "set_snapshot_path": entry.set_snapshot_path, + "report_dir": entry.report_dir, + "charts_dir": entry.charts_dir, + "tags": entry.tags, + "notes": entry.notes, + "verdict": entry.verdict, + } + }); + + if include_chart { + if let Some(charts_dir) = &entry.charts_dir { + let chart_path = Path::new(charts_dir).join("equity.png"); + if chart_path.exists() { + match fs::read(&chart_path) { + Ok(bytes) => { + let base64 = BASE64.encode(&bytes); + response["report"]["equity_chart_base64"] = json!(base64); + response["report"]["equity_chart_format"] = json!("png"); + } + Err(e) => { + response["report"]["equity_chart_error"] = json!(format!("Failed to read chart: {}", e)); + } + } + } else { + response["report"]["equity_chart_error"] = json!("equity.png not found in charts_dir"); + } + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": response.to_string() }], + "isError": false + })) + } + None => { + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": false, + "error": format!("Report with id '{}' not found", id) + }).to_string() }], + "isError": true + })) + } + } +} + +pub async fn handle_get_reports_summary(args: &Value) -> Result { + 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()), + verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()), + ..Default::default() + }; + + let stats = db.get_stats(&filters)?; + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "total_count": stats.total_count, + "profitable_count": stats.profitable_count, + "pass_verdict_count": stats.pass_verdict_count, + "fail_verdict_count": stats.fail_verdict_count, + "marginal_verdict_count": stats.marginal_verdict_count, + "avg_net_profit": stats.avg_net_profit, + "avg_profit_factor": stats.avg_profit_factor, + "avg_max_dd_pct": stats.avg_max_dd_pct, + "avg_win_rate_pct": stats.avg_win_rate_pct, + "avg_sharpe_ratio": stats.avg_sharpe_ratio, + "profitable_rate": if stats.total_count > 0 { + (stats.profitable_count as f64 / stats.total_count as f64 * 100.0).round() + } else { 0.0 }, + "pass_rate": if stats.total_count > 0 { + (stats.pass_verdict_count as f64 / stats.total_count as f64 * 100.0).round() + } else { 0.0 }, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_get_best_reports(args: &Value) -> Result { + let db = ReportDb::new(&Config::db_path()); + db.init()?; + + let sort_by = args.get("sort_by").and_then(|v| v.as_str()).unwrap_or("profit_factor"); + let order = args.get("order").and_then(|v| v.as_str()).unwrap_or("desc"); + let ascending = order == "asc"; + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; + + 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()), + verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()), + ..Default::default() + }; + + let entries = db.get_sorted_by(sort_by, ascending, limit, &filters)?; + + let reports: Vec = entries + .iter() + .map(|e| json!({ + "id": e.id, + "expert": e.expert, + "symbol": e.symbol, + "timeframe": e.timeframe, + "from_date": e.from_date, + "to_date": e.to_date, + "created_at": e.created_at, + "net_profit": e.net_profit, + "profit_factor": e.profit_factor, + "max_dd_pct": e.max_dd_pct, + "sharpe_ratio": e.sharpe_ratio, + "total_trades": e.total_trades, + "win_rate_pct": e.win_rate_pct, + "set_file": e.set_file_original, + "verdict": e.verdict, + "tags": e.tags, + })) + .collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "sort_by": sort_by, + "order": order, + "matched": reports.len(), + "reports": reports, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_search_reports_by_tags(args: &Value) -> Result { + let tags: Vec = 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()) + .ok_or_else(|| anyhow::anyhow!("tags array is required"))?; + + 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 entries = db.search_by_tags(&tags, limit)?; + + let reports: Vec = entries + .iter() + .map(|e| json!({ + "id": e.id, + "expert": e.expert, + "symbol": e.symbol, + "timeframe": e.timeframe, + "from_date": e.from_date, + "to_date": e.to_date, + "created_at": e.created_at, + "net_profit": e.net_profit, + "profit_factor": e.profit_factor, + "max_dd_pct": e.max_dd_pct, + "total_trades": e.total_trades, + "win_rate_pct": e.win_rate_pct, + "set_file": e.set_file_original, + "verdict": e.verdict, + "tags": e.tags, + "notes": e.notes, + })) + .collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "tags": tags, + "matched": reports.len(), + "reports": reports, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_search_reports_by_date_range(args: &Value) -> Result { + let from_start = args.get("from_start").and_then(|v| v.as_str()); + let from_end = args.get("from_end").and_then(|v| v.as_str()); + let to_start = args.get("to_start").and_then(|v| v.as_str()); + let to_end = args.get("to_end").and_then(|v| v.as_str()); + 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 entries = db.search_by_date_range(from_start, from_end, to_start, to_end, limit)?; + + let reports: Vec = entries + .iter() + .map(|e| json!({ + "id": e.id, + "expert": e.expert, + "symbol": e.symbol, + "timeframe": e.timeframe, + "from_date": e.from_date, + "to_date": e.to_date, + "created_at": e.created_at, + "net_profit": e.net_profit, + "profit_factor": e.profit_factor, + "max_dd_pct": e.max_dd_pct, + "total_trades": e.total_trades, + "win_rate_pct": e.win_rate_pct, + "set_file": e.set_file_original, + "verdict": e.verdict, + "tags": e.tags, + })) + .collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "from_start": from_start, + "from_end": from_end, + "to_start": to_start, + "to_end": to_end, + "matched": reports.len(), + "reports": reports, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_search_reports_by_notes(args: &Value) -> Result { + let query = args.get("query") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("query is required"))?; + 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 entries = db.search_by_notes(query, limit)?; + + let reports: Vec = entries + .iter() + .map(|e| json!({ + "id": e.id, + "expert": e.expert, + "symbol": e.symbol, + "timeframe": e.timeframe, + "from_date": e.from_date, + "to_date": e.to_date, + "created_at": e.created_at, + "net_profit": e.net_profit, + "profit_factor": e.profit_factor, + "max_dd_pct": e.max_dd_pct, + "total_trades": e.total_trades, + "win_rate_pct": e.win_rate_pct, + "set_file": e.set_file_original, + "verdict": e.verdict, + "notes": e.notes, + "tags": e.tags, + })) + .collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "query": query, + "matched": reports.len(), + "reports": reports, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_get_reports_by_set_file(args: &Value) -> Result { + let set_file = args.get("set_file") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("set_file is required"))?; + 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 entries = db.search_by_set_file(set_file, limit)?; + + let reports: Vec = entries + .iter() + .map(|e| json!({ + "id": e.id, + "expert": e.expert, + "symbol": e.symbol, + "timeframe": e.timeframe, + "from_date": e.from_date, + "to_date": e.to_date, + "created_at": e.created_at, + "net_profit": e.net_profit, + "profit_factor": e.profit_factor, + "max_dd_pct": e.max_dd_pct, + "total_trades": e.total_trades, + "win_rate_pct": e.win_rate_pct, + "set_file_original": e.set_file_original, + "set_snapshot_path": e.set_snapshot_path, + "verdict": e.verdict, + "tags": e.tags, + })) + .collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "set_file": set_file, + "matched": reports.len(), + "reports": reports, + }).to_string() }], + "isError": false + })) +} + +pub async fn handle_get_comparable_reports(args: &Value) -> Result { + let db = ReportDb::new(&Config::db_path()); + db.init()?; + + // Get expert/symbol/timeframe either from report_id or direct args + let (expert, symbol, timeframe, exclude_id) = if let Some(id) = args.get("report_id").and_then(|v| v.as_str()) { + match db.get_by_id(id)? { + Some(entry) => { + let exclude = args.get("exclude_id").and_then(|v| v.as_str()).map(|s| s.to_string()); + (entry.expert, entry.symbol, entry.timeframe, exclude.unwrap_or_else(|| id.to_string())) + } + None => return Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": false, + "error": format!("Report with id '{}' not found", id) + }).to_string() }], + "isError": true + })) + } + } else { + let expert = args.get("expert").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("expert or report_id is required"))?; + let symbol = args.get("symbol").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("symbol or report_id is required"))?; + let timeframe = args.get("timeframe").and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("timeframe or report_id is required"))?; + let exclude_id = args.get("exclude_id").and_then(|v| v.as_str()).map(|s| s.to_string()); + (expert.to_string(), symbol.to_string(), timeframe.to_string(), exclude_id.unwrap_or_default()) + }; + + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; + let exclude_opt = if exclude_id.is_empty() { None } else { Some(exclude_id.as_str()) }; + + let entries = db.get_comparable(&expert, &symbol, &timeframe, exclude_opt, limit)?; + + let reports: Vec = entries + .iter() + .map(|e| json!({ + "id": e.id, + "expert": e.expert, + "symbol": e.symbol, + "timeframe": e.timeframe, + "from_date": e.from_date, + "to_date": e.to_date, + "created_at": e.created_at, + "net_profit": e.net_profit, + "profit_factor": e.profit_factor, + "max_dd_pct": e.max_dd_pct, + "total_trades": e.total_trades, + "win_rate_pct": e.win_rate_pct, + "set_file": e.set_file_original, + "verdict": e.verdict, + "tags": e.tags, + })) + .collect(); + + Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "expert": expert, + "symbol": symbol, + "timeframe": timeframe, + "exclude_id": exclude_id, + "matched": reports.len(), + "reports": reports, + }).to_string() }], + "isError": false + })) +} diff --git a/src/tools/handlers/utility.rs b/src/tools/handlers/utility.rs index 2b68cf3..79fc62a 100644 --- a/src/tools/handlers/utility.rs +++ b/src/tools/handlers/utility.rs @@ -841,3 +841,878 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result Result { + let mut diagnostics = json!({ + "wine_executable": null, + "wine_version": null, + "wine_prefix": null, + "prefix_health": null, + "prefix_exists": false, + "prefix_size_mb": 0, + "errors": Vec::::new(), + "warnings": Vec::::new(), + }); + + // Check wine executable + if let Some(wine_exe) = config.wine_executable.as_ref() { + diagnostics["wine_executable"] = json!(wine_exe); + + // Get Wine version + let version_output = std::process::Command::new(wine_exe) + .arg("--version") + .output(); + + match version_output { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + diagnostics["wine_version"] = json!(version); + } + _ => { + diagnostics["errors"].as_array_mut().unwrap().push( + json!("Failed to get Wine version - Wine may not be properly installed") + ); + } + } + } else { + diagnostics["errors"].as_array_mut().unwrap().push( + json!("Wine executable not configured") + ); + } + + // Check Wine prefix + if let Some(mt5_dir) = config.mt5_dir() { + let wine_prefix = mt5_dir + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()); + + if let Some(prefix) = wine_prefix { + diagnostics["wine_prefix"] = json!(prefix.to_string_lossy().to_string()); + + // Check if prefix exists + let prefix_exists = prefix.exists(); + diagnostics["prefix_exists"] = json!(prefix_exists); + + if prefix_exists { + // Calculate prefix size + let mut total_size = 0u64; + fn calculate_size(dir: &Path, total: &mut u64) { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Ok(meta) = entry.metadata() { + *total += meta.len(); + } + } else if path.is_dir() { + calculate_size(&path, total); + } + } + } + } + calculate_size(prefix, &mut total_size); + diagnostics["prefix_size_mb"] = json!((total_size / 1024 / 1024) as i64); + + // Check critical directories + let system32 = prefix.join("drive_c/windows/system32"); + let program_files = prefix.join("drive_c/Program Files"); + + if !system32.exists() { + diagnostics["errors"].as_array_mut().unwrap().push( + json!("Wine prefix missing system32 directory - prefix may be corrupted") + ); + diagnostics["prefix_health"] = json!("corrupted"); + } else if !program_files.exists() { + diagnostics["warnings"].as_array_mut().unwrap().push( + json!("Program Files directory not found") + ); + diagnostics["prefix_health"] = json!("incomplete"); + } else { + diagnostics["prefix_health"] = json!("healthy"); + } + + // Check for recent Wine errors + let wine_log = prefix.join("wine.log"); + if wine_log.exists() { + if let Ok(content) = fs::read_to_string(&wine_log) { + let recent_errors: Vec<&str> = content.lines() + .filter(|l| l.contains("err:") || l.contains("fixme:")) + .rev() + .take(10) + .collect(); + if !recent_errors.is_empty() { + diagnostics["recent_wine_errors"] = json!(recent_errors); + } + } + } + } else { + diagnostics["errors"].as_array_mut().unwrap().push( + json!("Wine prefix directory does not exist") + ); + diagnostics["prefix_health"] = json!("missing"); + } + } else { + diagnostics["errors"].as_array_mut().unwrap().push( + json!("Could not determine Wine prefix from MT5 directory") + ); + } + } else { + diagnostics["errors"].as_array_mut().unwrap().push( + json!("MT5 directory not configured") + ); + } + + let has_errors = !diagnostics["errors"].as_array().unwrap().is_empty(); + + Ok(json!({ + "content": [{ "type": "text", "text": diagnostics.to_string() }], + "isError": has_errors + })) +} + +/// Get MT5 terminal logs +pub async fn handle_get_mt5_logs(config: &Config, args: &Value) -> Result { + let log_type = args.get("log_type").and_then(|v| v.as_str()).unwrap_or("terminal"); + let lines = args.get("lines").and_then(|v| v.as_u64()).unwrap_or(100) as usize; + let search = args.get("search").and_then(|v| v.as_str()); + + let mt5_dir = config.mt5_dir() + .ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?; + + let log_path = match log_type { + "terminal" => mt5_dir.join("logs").join(format!("{}", chrono::Local::now().format("%Y%m%d"))), + "tester" => mt5_dir.join("Tester").join("logs"), + "metaeditor" => mt5_dir.join("MetaEditor").join("logs"), + _ => mt5_dir.join("logs"), + }; + + let mut result = json!({ + "log_type": log_type, + "log_path": log_path.to_string_lossy().to_string(), + "found": false, + "lines_total": 0, + "lines_returned": 0, + "content": Vec::::new(), + }); + + // Find log files + let mut log_files: Vec<_> = Vec::new(); + if log_path.exists() { + if let Ok(entries) = fs::read_dir(&log_path) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + if let Some(ext) = path.extension() { + if ext == "log" { + if let Ok(meta) = entry.metadata() { + if let Ok(modified) = meta.modified() { + log_files.push((path, modified)); + } + } + } + } + } + } + } + } + + // Sort by modification time (newest first) + log_files.sort_by(|a, b| b.1.cmp(&a.1)); + + if let Some((latest_log, _)) = log_files.first() { + result["found"] = json!(true); + + if let Ok(content) = fs::read_to_string(latest_log) { + let all_lines: Vec<&str> = content.lines().collect(); + result["lines_total"] = json!(all_lines.len()); + + // Filter and limit lines + let mut filtered: Vec<&str> = all_lines.clone(); + + // Apply search filter + if let Some(search_term) = search { + let search_lower = search_term.to_lowercase(); + filtered.retain(|line| line.to_lowercase().contains(&search_lower)); + } + + // Get last N lines + let start = filtered.len().saturating_sub(lines); + let final_lines: Vec = filtered[start..].iter().map(|s| s.to_string()).collect(); + + result["lines_returned"] = json!(final_lines.len()); + result["content"] = json!(final_lines); + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": result.to_string() }], + "isError": false + })) +} + +/// Search MT5 logs for error patterns +pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result { + let error_patterns = vec![ + "error", "failed", "crash", "exception", "access violation", + "out of memory", "cannot", "unable to", "terminated" + ]; + let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(24); + let max_errors = args.get("max_errors").and_then(|v| v.as_u64()).unwrap_or(50) as usize; + + let mt5_dir = config.mt5_dir() + .ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?; + + let mut errors_found = Vec::new(); + let logs_dir = mt5_dir.join("logs"); + let cutoff_time = std::time::SystemTime::now() - std::time::Duration::from_secs(hours_back * 3600); + + // Search recent log files + if logs_dir.exists() { + if let Ok(entries) = fs::read_dir(&logs_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "log").unwrap_or(false) { + if let Ok(meta) = entry.metadata() { + if let Ok(modified) = meta.modified() { + if modified >= cutoff_time { + if let Ok(content) = fs::read_to_string(&path) { + for (i, line) in content.lines().enumerate() { + let line_lower = line.to_lowercase(); + for pattern in &error_patterns { + if line_lower.contains(pattern) { + errors_found.push(json!({ + "file": path.file_name().unwrap_or_default().to_string_lossy().to_string(), + "line": i + 1, + "content": line.trim().to_string(), + "pattern": pattern, + })); + if errors_found.len() >= max_errors { + break; + } + } + } + } + } + } + } + } + } + if errors_found.len() >= max_errors { + break; + } + } + } + } + + let result = json!({ + "hours_searched": hours_back, + "errors_found": errors_found.len(), + "max_errors": max_errors, + "errors": errors_found, + "suggestion": if errors_found.is_empty() { + "No errors found in recent logs. Check get_mt5_logs for full log content." + } else { + "Found potential errors. Review the 'content' field for details." + }, + }); + + Ok(json!({ + "content": [{ "type": "text", "text": result.to_string() }], + "isError": false + })) +} + +/// Check MT5 process status +pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result { + use std::process::Command; + + let mut result = json!({ + "is_running": false, + "processes": Vec::::new(), + "wine_server_running": false, + "total_instances": 0, + }); + + #[cfg(target_os = "macos")] + { + // Check for MT5 processes + let ps_output = Command::new("ps") + .args(["aux"]) + .output(); + + if let Ok(output) = ps_output { + let content = String::from_utf8_lossy(&output.stdout); + let mut processes = Vec::new(); + let mut mt5_count = 0; + let mut wine_server = false; + + for line in content.lines() { + let line_lower = line.to_lowercase(); + + if line_lower.contains("terminal64") || line_lower.contains("metatrader") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 11 { + processes.push(json!({ + "pid": parts[1], + "cpu": parts[2], + "mem": parts[3], + "command": parts[10..].join(" "), + })); + mt5_count += 1; + } + } + + if line_lower.contains("wineserver") { + wine_server = true; + } + } + + result["processes"] = json!(processes); + result["is_running"] = json!(mt5_count > 0); + result["total_instances"] = json!(mt5_count); + result["wine_server_running"] = json!(wine_server); + } + } + + #[cfg(target_os = "linux")] + { + let ps_output = Command::new("ps") + .args(["aux"]) + .output(); + + if let Ok(output) = ps_output { + let content = String::from_utf8_lossy(&output.stdout); + let mut processes = Vec::new(); + let mut mt5_count = 0; + let mut wine_server = false; + + for line in content.lines() { + let line_lower = line.to_lowercase(); + + if line_lower.contains("terminal64") || line_lower.contains("metatrader") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 11 { + processes.push(json!({ + "pid": parts[1], + "cpu": parts[2], + "mem": parts[3], + "command": parts[10..].join(" "), + })); + mt5_count += 1; + } + } + + if line_lower.contains("wineserver") { + wine_server = true; + } + } + + result["processes"] = json!(processes); + result["is_running"] = json!(mt5_count > 0); + result["total_instances"] = json!(mt5_count); + result["wine_server_running"] = json!(wine_server); + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": result.to_string() }], + "isError": false + })) +} + +/// Kill stuck MT5 process +pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result { + use std::process::Command; + + let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false); + let pid = args.get("pid").and_then(|v| v.as_str()); + + let mut killed = Vec::new(); + let mut failed = Vec::new(); + + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + // Get list of MT5 processes + let ps_output = Command::new("ps") + .args(["aux"]) + .output(); + + if let Ok(output) = ps_output { + let content = String::from_utf8_lossy(&output.stdout); + + for line in content.lines() { + let line_lower = line.to_lowercase(); + + let should_kill = if let Some(target_pid) = pid { + line.contains(target_pid) && (line_lower.contains("terminal64") || line_lower.contains("metatrader")) + } else { + line_lower.contains("terminal64") || line_lower.contains("metatrader") + }; + + if should_kill { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 2 { + let process_pid = parts[1]; + let signal = if force { "-9" } else { "-15" }; + + match Command::new("kill").args([signal, process_pid]).output() { + Ok(_) => killed.push(process_pid.to_string()), + Err(e) => failed.push(format!("{}: {}", process_pid, e)), + } + } + } + } + } + + // Also kill wineserver if force=true + if force { + let _ = Command::new("killall").arg("wineserver").output(); + } + } + + let message = if killed.is_empty() { + "No MT5 processes found to kill".to_string() + } else { + format!("Killed {} MT5 process(es)", killed.len()) + }; + + let result = json!({ + "killed": killed, + "failed": failed, + "force": force, + "message": message, + }); + + Ok(json!({ + "content": [{ "type": "text", "text": result.to_string() }], + "isError": !failed.is_empty() + })) +} + +/// Check system resources for MT5 +pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> Result { + use std::process::Command; + + let mut result = json!({ + "disk_space": null, + "memory": null, + "cpu_cores": 0, + "recommendations": Vec::::new(), + }); + + #[cfg(any(target_os = "macos", target_os = "linux"))] + { + // Check disk space + let df_output = Command::new("df") + .args(["-h", "/"]) + .output(); + + if let Ok(output) = df_output { + let content = String::from_utf8_lossy(&output.stdout); + for line in content.lines().skip(1) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 5 { + result["disk_space"] = json!({ + "filesystem": parts[0], + "size": parts[1], + "used": parts[2], + "available": parts[3], + "use_percent": parts[4], + }); + + // Check if low on space + let use_pct = parts[4].trim_end_matches('%').parse::().unwrap_or(0); + if use_pct > 90 { + result["recommendations"].as_array_mut().unwrap().push( + json!("Disk space critically low. Clean MT5 cache with clean_cache.") + ); + } else if use_pct > 80 { + result["recommendations"].as_array_mut().unwrap().push( + json!("Disk space getting low. Consider cleaning cache.") + ); + } + } + } + } + + // Check memory + #[cfg(target_os = "macos")] + { + let vm_output = Command::new("vm_stat").output(); + if let Ok(output) = vm_output { + let content = String::from_utf8_lossy(&output.stdout); + // Parse vm_stat output + let mut free_pages = 0u64; + let mut active_pages = 0u64; + let mut inactive_pages = 0u64; + + for line in content.lines() { + if line.contains("Pages free:") { + free_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0); + } else if line.contains("Pages active:") { + active_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0); + } else if line.contains("Pages inactive:") { + inactive_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0); + } + } + + let page_size = 4096u64; + let total_mb = ((free_pages + active_pages + inactive_pages) * page_size) / 1024 / 1024; + let free_mb = (free_pages * page_size) / 1024 / 1024; + + result["memory"] = json!({ + "total_mb": total_mb, + "free_mb": free_mb, + "unit": "MB", + }); + + if free_mb < 2048 { + result["recommendations"].as_array_mut().unwrap().push( + json!("Low memory available. MT5 may crash during large optimizations.") + ); + } + } + } + + #[cfg(target_os = "linux")] + { + let mem_output = Command::new("free").args(["-m"]).output(); + if let Ok(output) = mem_output { + let content = String::from_utf8_lossy(&output.stdout); + for line in content.lines() { + if line.starts_with("Mem:") { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 4 { + result["memory"] = json!({ + "total_mb": parts[1].parse::().unwrap_or(0), + "used_mb": parts[2].parse::().unwrap_or(0), + "free_mb": parts[3].parse::().unwrap_or(0), + "unit": "MB", + }); + } + } + } + } + } + + // Get CPU cores + let nproc_output = Command::new("sysctl") + .args(["-n", "hw.ncpu"]) + .output(); + + if let Ok(output) = nproc_output { + let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0); + result["cpu_cores"] = json!(cores); + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": result.to_string() }], + "isError": false + })) +} + +/// Validate MT5 configuration files +pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Result { + let mt5_dir = config.mt5_dir() + .ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?; + + let mut result = json!({ + "terminal_ini": null, + "tester_ini": null, + "config_files_found": Vec::::new(), + "errors": Vec::::new(), + "warnings": Vec::::new(), + }); + + // Check terminal.ini + let terminal_ini = mt5_dir.join("terminal.ini"); + if terminal_ini.exists() { + result["config_files_found"].as_array_mut().unwrap().push(json!("terminal.ini")); + + if let Ok(content) = fs::read_to_string(&terminal_ini) { + // Check for common issues + if !content.contains("[Common]") { + result["errors"].as_array_mut().unwrap().push( + json!("terminal.ini missing [Common] section") + ); + } + + // Extract key settings + let mut settings = serde_json::Map::new(); + for line in content.lines() { + if line.starts_with("Login=") { + settings.insert("login".to_string(), json!(line.trim_start_matches("Login="))); + } else if line.starts_with("Server=") { + settings.insert("server".to_string(), json!(line.trim_start_matches("Server="))); + } else if line.starts_with("Expert=") { + settings.insert("expert".to_string(), json!(line.trim_start_matches("Expert="))); + } + } + result["terminal_ini"] = json!(settings); + } else { + result["errors"].as_array_mut().unwrap().push( + json!("Could not read terminal.ini") + ); + } + } else { + result["warnings"].as_array_mut().unwrap().push( + json!("terminal.ini not found") + ); + } + + // Check for tester config + let tester_dir = mt5_dir.join("Tester"); + if tester_dir.exists() { + if let Ok(entries) = fs::read_dir(&tester_dir) { + let ini_files: Vec = entries + .flatten() + .filter_map(|e| { + let p = e.path(); + if p.extension()?.to_str()? == "ini" { + Some(p.file_name()?.to_string_lossy().to_string()) + } else { + None + } + }) + .collect(); + + if !ini_files.is_empty() { + result["tester_ini"] = json!(ini_files); + } + } + } + + // Check for common problems + let experts_dir = mt5_dir.join("MQL5").join("Experts"); + if !experts_dir.exists() { + result["errors"].as_array_mut().unwrap().push( + json!("MQL5/Experts directory not found - MT5 installation may be incomplete") + ); + } + + let has_errors = !result["errors"].as_array().unwrap().is_empty(); + + Ok(json!({ + "content": [{ "type": "text", "text": result.to_string() }], + "isError": has_errors + })) +} + +/// Get Wine prefix detailed information +pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Result { + let mt5_dir = config.mt5_dir() + .ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?; + + let wine_prefix = mt5_dir + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()); + + let mut result = json!({ + "prefix_path": null, + "exists": false, + "windows_version": null, + "dll_overrides": Vec::::new(), + "installed_programs": Vec::::new(), + "registry_files": Vec::::new(), + "drive_c_size_mb": 0, + }); + + if let Some(prefix) = wine_prefix { + result["prefix_path"] = json!(prefix.to_string_lossy().to_string()); + result["exists"] = json!(prefix.exists()); + + if prefix.exists() { + // Check Windows version + let system_reg = prefix.join("system.reg"); + if system_reg.exists() { + if let Ok(content) = fs::read_to_string(&system_reg) { + for line in content.lines().take(50) { + if line.contains("\"ProductName\"") { + let parts: Vec<&str> = line.split('"').collect(); + if parts.len() >= 4 { + result["windows_version"] = json!(parts[3]); + } + } + } + } + } + + // Calculate drive_c size + let drive_c = prefix.join("drive_c"); + if drive_c.exists() { + let mut size = 0u64; + fn calc_size(dir: &Path, total: &mut u64) { + if let Ok(entries) = fs::read_dir(dir) { + for e in entries.flatten() { + let p = e.path(); + if p.is_file() { + if let Ok(m) = e.metadata() { + *total += m.len(); + } + } else if p.is_dir() { + calc_size(&p, total); + } + } + } + } + calc_size(&drive_c, &mut size); + result["drive_c_size_mb"] = json!((size / 1024 / 1024) as i64); + } + + // Check for installed programs + let prog_files = prefix.join("drive_c").join("Program Files"); + if prog_files.exists() { + if let Ok(entries) = fs::read_dir(&prog_files) { + let programs: Vec = entries + .flatten() + .filter_map(|e| { + let p = e.path(); + if p.is_dir() { + Some(p.file_name()?.to_string_lossy().to_string()) + } else { + None + } + }) + .collect(); + result["installed_programs"] = json!(programs); + } + } + + // List registry files + let reg_files: Vec = vec![ + "system.reg", "user.reg", "userdef.reg" + ] + .into_iter() + .filter(|f| prefix.join(f).exists()) + .map(|f| f.to_string()) + .collect(); + result["registry_files"] = json!(reg_files); + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": result.to_string() }], + "isError": false + })) +} + +/// Get backtest crash/failure information +pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Result { + let report_dir = args.get("report_dir").and_then(|v| v.as_str()); + let check_recent = args.get("check_recent").and_then(|v| v.as_bool()).unwrap_or(true); + let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(6); + + let mut result = json!({ + "crashes_found": Vec::::new(), + "recent_failures": 0, + "common_patterns": Vec::::new(), + }); + + // Check specific report directory if provided + if let Some(dir) = report_dir { + let path = Path::new(dir); + if path.exists() { + // Check for incomplete markers + let incomplete_marker = path.join(".incomplete"); + let error_log = path.join("error.log"); + + if incomplete_marker.exists() { + result["crashes_found"].as_array_mut().unwrap().push(json!({ + "report_dir": dir, + "type": "incomplete", + "reason": "Backtest was interrupted or timed out", + })); + } + + if error_log.exists() { + if let Ok(content) = fs::read_to_string(&error_log) { + result["crashes_found"].as_array_mut().unwrap().push(json!({ + "report_dir": dir, + "type": "error_log", + "content": content.lines().take(20).collect::>().join("\n"), + })); + } + } + + // Check if deals.csv is missing or empty + let deals_csv = path.join("deals.csv"); + if !deals_csv.exists() { + result["crashes_found"].as_array_mut().unwrap().push(json!({ + "report_dir": dir, + "type": "missing_deals", + "reason": "deals.csv not found - backtest likely failed", + })); + } else if let Ok(meta) = deals_csv.metadata() { + if meta.len() < 100 { + result["crashes_found"].as_array_mut().unwrap().push(json!({ + "report_dir": dir, + "type": "empty_deals", + "reason": "deals.csv is nearly empty - no trades were made", + })); + } + } + } + } + + // Check recent reports if requested + if check_recent { + let reports_dir_str = config.get("reports_dir"); + let reports_dir = Path::new(&reports_dir_str); + if reports_dir.exists() { + let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(hours_back * 3600); + + if let Ok(entries) = fs::read_dir(&reports_dir) { + let mut failures = 0; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + if let Ok(meta) = entry.metadata() { + if let Ok(modified) = meta.modified() { + if modified >= cutoff { + // Check for failure indicators + let has_deals = path.join("deals.csv").exists(); + let has_incomplete = path.join(".incomplete").exists(); + + if !has_deals || has_incomplete { + failures += 1; + } + } + } + } + } + } + result["recent_failures"] = json!(failures); + } + } + } + + // Analyze common patterns + let crashes = result["crashes_found"].as_array().unwrap(); + if !crashes.is_empty() { + let types: Vec = crashes.iter() + .filter_map(|c| c.get("type").and_then(|v| v.as_str()).map(|s| s.to_string())) + .collect(); + + if types.contains(&"missing_deals".to_string()) { + result["common_patterns"].as_array_mut().unwrap().push( + json!("Missing deals.csv suggests MT5 crashed during backtest") + ); + } + if types.contains(&"incomplete".to_string()) { + result["common_patterns"].as_array_mut().unwrap().push( + json!("Incomplete markers indicate interruptions - check system resources") + ); + } + } + + Ok(json!({ + "content": [{ "type": "text", "text": result.to_string() }], + "isError": false + })) +}