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
This commit is contained in:
Generated
+1
-1
@@ -481,7 +481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mt5-quant"
|
||||
version = "1.29.0"
|
||||
version = "1.29.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -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 <masdevid@example.com>"]
|
||||
|
||||
@@ -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)**
|
||||
|
||||
---
|
||||
|
||||
+4
-2
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
+610
-1
@@ -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<ProfitBucket>,
|
||||
}
|
||||
|
||||
#[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<HourPerformance>,
|
||||
pub by_day: Vec<DayPerformance>,
|
||||
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<HoldTimeBucket>,
|
||||
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<VolumeBucket>,
|
||||
}
|
||||
|
||||
#[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<ProfitBucket> = 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<i32, (i32, i32, f64)> = HashMap::new();
|
||||
let mut daily: HashMap<String, (i32, i32, f64, i32)> = 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<HourPerformance> = 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<DayPerformance> = 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<String, DateTime<chrono::Utc>> = 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::<f64>() / hold_times.len() as f64;
|
||||
let mut sorted_hold: Vec<f64> = 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::<f64>();
|
||||
let sum_y = hold_times.iter().map(|(_, p)| *p).sum::<f64>();
|
||||
let sum_xy = hold_times.iter().map(|(h, p)| h * p).sum::<f64>();
|
||||
let sum_x2 = hold_times.iter().map(|(h, _)| h * h).sum::<f64>();
|
||||
let sum_y2 = hold_times.iter().map(|(_, p)| p * p).sum::<f64>();
|
||||
|
||||
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<HoldTimeBucket> = 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<LayerPerformance> {
|
||||
let mut layer_stats: HashMap<i32, (i32, i32, f64, f64)> = 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<LayerPerformance> = 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<VolumeBucket> = 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<String, DateTime<chrono::Utc>> = 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<DateTime<chrono::Utc>> = 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,4 +374,543 @@ impl ReportDb {
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Get a specific report by ID
|
||||
pub fn get_by_id(&self, id: &str) -> Result<Option<ReportEntry>> {
|
||||
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<String> = 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<Vec<ReportEntry>> {
|
||||
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<String> = 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<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = 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<Vec<ReportEntry>> {
|
||||
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<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = 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<Vec<ReportEntry>> {
|
||||
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<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = 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<Vec<ReportEntry>> {
|
||||
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<String> = 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<ReportEntry> = 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<String> = 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<Vec<ReportEntry>> {
|
||||
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<String> = 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<ReportEntry> = 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<String> = 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<Vec<ReportEntry>> {
|
||||
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<String> = 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<ReportEntry> = 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<String> = 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<ReportStats> {
|
||||
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<String> = 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<i64> = row.get(6)?;
|
||||
let pass_count: Option<i64> = row.get(7)?;
|
||||
let fail_count: Option<i64> = row.get(8)?;
|
||||
let marginal_count: Option<i64> = 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<f64>,
|
||||
pub avg_profit_factor: Option<f64>,
|
||||
pub avg_max_dd_pct: Option<f64>,
|
||||
pub avg_win_rate_pct: Option<f64>,
|
||||
pub avg_sharpe_ratio: Option<f64>,
|
||||
pub profitable_count: usize,
|
||||
pub pass_verdict_count: usize,
|
||||
pub fail_verdict_count: usize,
|
||||
pub marginal_verdict_count: usize,
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = 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<Value> = 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<Value> {
|
||||
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<Value> = 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<Value> {
|
||||
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<Value> = 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<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = 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<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = 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<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = 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<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = 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<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = 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<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = 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<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -433,3 +433,418 @@ pub async fn handle_annotate_history(args: &Value) -> Result<Value> {
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_get_report_by_id(_config: &Config, args: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"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<Value> {
|
||||
let tags: Vec<String> = args
|
||||
.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.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<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"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<Value> {
|
||||
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<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"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<Value> {
|
||||
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<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"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<Value> {
|
||||
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<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file_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<Value> {
|
||||
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<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"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
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -841,3 +841,878 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result<Valu
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
// === Wine & MT5 Debugging Tools ===
|
||||
|
||||
/// Diagnose Wine installation and prefix health
|
||||
pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Value> {
|
||||
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::<String>::new(),
|
||||
"warnings": Vec::<String>::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<Value> {
|
||||
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::<String>::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<String> = 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<Value> {
|
||||
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<Value> {
|
||||
use std::process::Command;
|
||||
|
||||
let mut result = json!({
|
||||
"is_running": false,
|
||||
"processes": Vec::<serde_json::Value>::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<Value> {
|
||||
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<Value> {
|
||||
use std::process::Command;
|
||||
|
||||
let mut result = json!({
|
||||
"disk_space": null,
|
||||
"memory": null,
|
||||
"cpu_cores": 0,
|
||||
"recommendations": Vec::<String>::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::<u32>().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::<u64>().unwrap_or(0),
|
||||
"used_mb": parts[2].parse::<u64>().unwrap_or(0),
|
||||
"free_mb": parts[3].parse::<u64>().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<Value> {
|
||||
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::<String>::new(),
|
||||
"errors": Vec::<String>::new(),
|
||||
"warnings": Vec::<String>::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<String> = 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<Value> {
|
||||
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::<String>::new(),
|
||||
"installed_programs": Vec::<String>::new(),
|
||||
"registry_files": Vec::<String>::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<String> = 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<String> = 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<Value> {
|
||||
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::<serde_json::Value>::new(),
|
||||
"recent_failures": 0,
|
||||
"common_patterns": Vec::<String>::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::<Vec<_>>().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<String> = 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
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user