From 839e33023a5b27a94229708e3375ad6a5334cdfd Mon Sep 17 00:00:00 2001 From: Devid HW Date: Tue, 25 Aug 2026 00:38:44 +0700 Subject: [PATCH] fix: remove PayPal funding link, bump anyhow for RUSTSEC-2026-0190, resolve all clippy warnings - Remove PayPal donate URL from .github/FUNDING.yml (keep saweria), fix trailing whitespace - Update anyhow 1.0.102 -> 1.0.104 (RUSTSEC-2026-0190 unsoundness warning) - Fix 25 clippy warnings across src/ (idiomatic fixes; two justified #[allow(too_many_arguments)] on internal pass-through helpers) - Normalize formatting via cargo fmt - Correct stale version reference in AGENTS.md (1.32.4 -> 1.34.0) --- .github/FUNDING.yml | 4 +- AGENTS.md | 2 +- Cargo.lock | 4 +- src/analytics/analyze.rs | 232 ++++++-- src/analytics/extract.rs | 152 +++-- src/analytics/mod.rs | 4 +- src/compile/mql_compiler.rs | 128 +++-- src/main.rs | 61 +- src/mcp_server.rs | 118 ++-- src/models/config.rs | 288 +++++----- src/models/metrics.rs | 53 +- src/optimization/optimizer.rs | 187 ++++--- src/optimization/parser.rs | 195 ++++--- src/pipeline/backtest.rs | 760 +++++++++++++++++-------- src/storage/database.rs | 48 +- src/tools/definitions/analytics.rs | 3 +- src/tools/definitions/experts.rs | 40 +- src/tools/definitions/mod.rs | 14 +- src/tools/handlers/analysis.rs | 211 +++++-- src/tools/handlers/backtest.rs | 475 +++++++++++----- src/tools/handlers/experts.rs | 111 +++- src/tools/handlers/mod.rs | 156 ++++-- src/tools/handlers/optimization.rs | 69 ++- src/tools/handlers/reports.rs | 682 ++++++++++++++--------- src/tools/handlers/setfiles.rs | 131 +++-- src/tools/handlers/system.rs | 212 ++++--- src/tools/handlers/utility.rs | 856 +++++++++++++++++------------ tests/integration_tests.rs | 17 +- 28 files changed, 3428 insertions(+), 1785 deletions(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 0ef41dd..fe573d1 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,4 +1,4 @@ # These are supported funding model platforms -github: masdevid -custom: ["https://www.paypal.me/devidhw", "https://saweria.co/depod"] +github: masdevid +custom: ["https://saweria.co/depod"] diff --git a/AGENTS.md b/AGENTS.md index 2bae54e..884120c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ MT5-Quant/ │ ├── integration_test.sh # Shell-based integration tests │ └── fixtures/ # Sample reports + CSVs for testing ├── server.json # MCP registry manifest (tracked) -├── Cargo.toml # version = 1.32.4 +├── Cargo.toml # version = 1.34.0 └── CHANGELOG.md ``` diff --git a/Cargo.lock b/Cargo.lock index 9a1436a..24026a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,9 +84,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "autocfg" diff --git a/src/analytics/analyze.rs b/src/analytics/analyze.rs index 6961d34..a31617a 100644 --- a/src/analytics/analyze.rs +++ b/src/analytics/analyze.rs @@ -88,7 +88,13 @@ impl DealAnalyzer { } else { 0.0 }; - balance_curve.push((deal.time.clone(), balance, dd_pct, deal.profit, deal.comment.clone())); + balance_curve.push(( + deal.time.clone(), + balance, + dd_pct, + deal.profit, + deal.comment.clone(), + )); } } @@ -142,7 +148,13 @@ impl DealAnalyzer { events } - fn build_dd_event(&self, curve: &[(String, f64, f64, f64, String)], start_idx: usize, peak_idx: usize, recovery_idx: Option) -> DrawdownEvent { + fn build_dd_event( + &self, + curve: &[(String, f64, f64, f64, String)], + start_idx: usize, + peak_idx: usize, + recovery_idx: Option, + ) -> DrawdownEvent { let (start_time, _, _, _, _) = &curve[start_idx]; let (peak_time, _, peak_dd, _, _) = &curve[peak_idx]; @@ -159,10 +171,13 @@ impl DealAnalyzer { if let Some(rec_idx) = recovery_idx { let (rec_time, _, _, _, _) = &curve[rec_idx]; event.recovery_date = Some(Self::extract_date(rec_time)); - + if let (Ok(start_dt), Ok(rec_dt)) = ( chrono::NaiveDate::parse_from_str(&event.start_date, "%Y-%m-%d"), - chrono::NaiveDate::parse_from_str(event.recovery_date.as_ref().unwrap(), "%Y-%m-%d") + chrono::NaiveDate::parse_from_str( + event.recovery_date.as_ref().unwrap(), + "%Y-%m-%d", + ), ) { event.recovery_days = Some((rec_dt - start_dt).num_days() as i32); } @@ -170,7 +185,7 @@ impl DealAnalyzer { if let (Ok(start_dt), Ok(end_dt)) = ( chrono::NaiveDate::parse_from_str(&event.start_date, "%Y-%m-%d"), - chrono::NaiveDate::parse_from_str(&event.end_date, "%Y-%m-%d") + chrono::NaiveDate::parse_from_str(&event.end_date, "%Y-%m-%d"), ) { event.duration_days = (end_dt - start_dt).num_days() as i32; } @@ -255,7 +270,7 @@ impl DealAnalyzer { if let Some(in_deal) = open_pos.remove(order) { if let (Some(dt_out), Some(dt_in)) = ( Self::parse_datetime(&deal.time), - Self::parse_datetime(&in_deal.time) + Self::parse_datetime(&in_deal.time), ) { let hold_minutes = (dt_out - dt_in).num_seconds() as f64 / 60.0; @@ -303,13 +318,20 @@ impl DealAnalyzer { .into_iter() .filter(|(_, (trades, _, _))| *trades > 0) .map(|(d, (trades, wins, total_pnl))| { - let avg_pnl = if trades > 0 { total_pnl / trades as f64 } else { 0.0 }; - (d, DirectionStats { - trades, - win_rate: ((wins as f64 / trades as f64) * 1000.0).round() / 10.0, - total_pnl: (total_pnl * 100.0).round() / 100.0, - avg_pnl: (avg_pnl * 100.0).round() / 100.0, - }) + let avg_pnl = if trades > 0 { + total_pnl / trades as f64 + } else { + 0.0 + }; + ( + d, + DirectionStats { + trades, + win_rate: ((wins as f64 / trades as f64) * 1000.0).round() / 10.0, + total_pnl: (total_pnl * 100.0).round() / 100.0, + avg_pnl: (avg_pnl * 100.0).round() / 100.0, + }, + ) }) .collect() } @@ -368,7 +390,11 @@ impl DealAnalyzer { max_loss_start, max_loss_end, current_streak: if last.profit > 0.0 { cur_win } else { cur_loss }, - current_streak_type: if last.profit > 0.0 { "win".to_string() } else { "loss".to_string() }, + current_streak_type: if last.profit > 0.0 { + "win".to_string() + } else { + "loss".to_string() + }, } } @@ -400,12 +426,15 @@ impl DealAnalyzer { } } - ConcurrentPeak { peak_open: peak, peak_time } + ConcurrentPeak { + peak_open: peak, + peak_time, + } } fn parse_datetime(time_str: &str) -> Option> { let s = time_str.trim(); - + let formats = [ "%Y.%m.%d %H:%M:%S", "%Y-%m-%d %H:%M:%S", @@ -735,7 +764,8 @@ impl DealAnalyzer { chrono::Weekday::Fri => "Fri", chrono::Weekday::Sat => "Sat", chrono::Weekday::Sun => "Sun", - }.to_string(); + } + .to_string(); let entry = hourly.entry(hour).or_insert((0, 0, 0.0)); entry.0 += 1; @@ -744,7 +774,9 @@ impl DealAnalyzer { entry.1 += 1; } - let day_entry = daily.entry(day_name.clone()).or_insert((0, 0, 0.0, day_num)); + 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 { @@ -760,7 +792,11 @@ impl DealAnalyzer { 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 }, + 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); @@ -773,15 +809,35 @@ impl DealAnalyzer { 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 }, + 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(); + 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, @@ -838,7 +894,11 @@ impl DealAnalyzer { 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 } + if denominator > 0.0 { + numerator / denominator + } else { + 0.0 + } } else { 0.0 }; @@ -863,7 +923,11 @@ impl DealAnalyzer { .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 }; + let avg_profit = if count > 0 { + total_pnl / count as f64 + } else { + 0.0 + }; HoldTimeBucket { range: range.to_string(), @@ -903,15 +967,29 @@ impl DealAnalyzer { let mut result: Vec = layer_stats .into_iter() - .map(|(layer, (trades, wins, total_pnl, total_volume))| LayerPerformance { - layer, - trades, - wins, - total_pnl: (total_pnl * 100.0).round() / 100.0, - win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 }, - avg_volume: if trades > 0 { (total_volume / trades as f64 * 10000.0).round() / 10000.0 } else { 0.0 }, - avg_profit: if trades > 0 { (total_pnl / trades as f64 * 100.0).round() / 100.0 } else { 0.0 }, - }) + .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); @@ -942,7 +1020,11 @@ impl DealAnalyzer { 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 } + if denominator > 0.0 { + numerator / denominator + } else { + 0.0 + } } else { 0.0 }; @@ -967,7 +1049,11 @@ impl DealAnalyzer { .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 }; + let avg_profit = if trades > 0 { + total_pnl / trades as f64 + } else { + 0.0 + }; VolumeBucket { volume_range: range.to_string(), @@ -990,10 +1076,21 @@ impl DealAnalyzer { 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 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 }; + 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 @@ -1003,21 +1100,44 @@ impl DealAnalyzer { 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 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 }; + 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 }, + 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, + cost_impact_on_win_rate: (win_rate_before_costs - current_win_rate * 100.0).round() + / 100.0, } } @@ -1062,7 +1182,11 @@ impl DealAnalyzer { } 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 }; + let avg_trade_duration = if total_trades > 0.0 { + total_hold_minutes / total_trades / 60.0 + } else { + 0.0 + }; // Get date range let dates: Vec> = deals @@ -1089,9 +1213,17 @@ impl DealAnalyzer { }; 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_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 }, + profit_per_trade_hour: if total_hold_hours > 0.0 { + (total_profit / total_hold_hours / total_trades * 100.0).round() / 100.0 + } else { + 0.0 + }, avg_trade_duration_hours: (avg_trade_duration * 10.0).round() / 10.0, annualized_return_pct: (annualized * 10.0).round() / 10.0, trades_per_day: (total_trades / total_days * 10.0).round() / 10.0, diff --git a/src/analytics/extract.rs b/src/analytics/extract.rs index 7bd68a2..597a1de 100644 --- a/src/analytics/extract.rs +++ b/src/analytics/extract.rs @@ -44,7 +44,9 @@ impl ReportExtractor { if let Ok(file) = fs::read(path) { let header = &file[..file.len().min(512)]; - if header.windows(5).any(|w| w == b" Result<(Metrics, Vec)> { let text = Self::read_text(path)?; - - let metrics = Metrics::from_html(&text) - .ok_or_else(|| anyhow!("No metrics found in HTML report"))?; - + + let metrics = + Metrics::from_html(&text).ok_or_else(|| anyhow!("No metrics found in HTML report"))?; + let deals = self.parse_deals_html(&text)?; Ok((metrics, deals)) @@ -65,10 +67,9 @@ impl ReportExtractor { fn parse_xml(&self, path: &str) -> Result<(Metrics, Vec)> { let text = Self::read_text(path)?; - - let metrics = Metrics::from_html(&text) - .unwrap_or_default(); - + + let metrics = Metrics::from_html(&text).unwrap_or_default(); + let deals = self.parse_deals_xml(&text)?; Ok((metrics, deals)) @@ -91,15 +92,16 @@ impl ReportExtractor { .map_err(|e| anyhow!("Cell regex error: {}", e))?; // Collect all blocks once, then find the deals header and parse from there. - let rows: Vec<&str> = row_re.captures_iter(text) + let rows: Vec<&str> = row_re + .captures_iter(text) .filter_map(|cap| cap.get(0).map(|m| m.as_str())) .collect(); // Find the header row index: it must contain both "Deal" and "Symbol" (case-insensitive). let header_idx = rows.iter().position(|row| { let lower = row.to_lowercase(); - (lower.contains(">deal<") || lower.contains(">deal symbol<") || lower.contains(">symbol deal<") || lower.contains(">deal symbol<") || lower.contains(">symbol time<") && lower.contains(">volume<") && lower.contains(">profit<") + lower.contains(">time<") + && lower.contains(">volume<") + && lower.contains(">profit<") }); match alt { Some(i) => i + 1, @@ -121,7 +125,8 @@ impl ReportExtractor { }; for row in &rows[start_idx..] { - let cells: Vec = cell_re.captures_iter(row) + let cells: Vec = cell_re + .captures_iter(row) .filter_map(|cap| cap.get(1)) .map(|m| Self::strip_tags(m.as_str())) .map(|s| s.replace(',', "")) @@ -132,35 +137,43 @@ impl ReportExtractor { } // Skip balance/credit operation rows (Type column is index 3 in MT5 HTML) - let type_cell = cells.get(3).map(|s| s.trim().to_lowercase()).unwrap_or_default(); + let type_cell = cells + .get(3) + .map(|s| s.trim().to_lowercase()) + .unwrap_or_default(); if type_cell == "balance" || type_cell == "credit" { continue; } // Also skip sub-header rows that repeat column names - if type_cell == "type" || cells.get(1).map(|s| s.trim().to_lowercase()).as_deref() == Some("deal") { + if type_cell == "type" + || cells.get(1).map(|s| s.trim().to_lowercase()).as_deref() == Some("deal") + { continue; } // Skip rows with no deal number (e.g. totals row) - let deal_num = cells.get(1).map(|s| s.trim().to_string()).unwrap_or_default(); + let deal_num = cells + .get(1) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); if deal_num.is_empty() { continue; } let deal = Deal { - time: cells.get(0).cloned().unwrap_or_default(), - deal: deal_num, - symbol: cells.get(2).cloned().unwrap_or_default(), - deal_type: cells.get(3).cloned().unwrap_or_default(), - entry: cells.get(4).cloned().unwrap_or_default(), - volume: cells.get(5).and_then(|s| s.parse().ok()).unwrap_or(0.0), - price: cells.get(6).and_then(|s| s.parse().ok()).unwrap_or(0.0), - order: cells.get(7).cloned().unwrap_or_default(), + time: cells.first().cloned().unwrap_or_default(), + deal: deal_num, + symbol: cells.get(2).cloned().unwrap_or_default(), + deal_type: cells.get(3).cloned().unwrap_or_default(), + entry: cells.get(4).cloned().unwrap_or_default(), + volume: cells.get(5).and_then(|s| s.parse().ok()).unwrap_or(0.0), + price: cells.get(6).and_then(|s| s.parse().ok()).unwrap_or(0.0), + order: cells.get(7).cloned().unwrap_or_default(), commission: cells.get(8).and_then(|s| s.parse().ok()).unwrap_or(0.0), - swap: cells.get(9).and_then(|s| s.parse().ok()).unwrap_or(0.0), - profit: cells.get(10).and_then(|s| s.parse().ok()).unwrap_or(0.0), - balance: cells.get(11).and_then(|s| s.parse().ok()).unwrap_or(0.0), - comment: cells.get(12).cloned().unwrap_or_default(), - magic: cells.get(13).cloned(), + swap: cells.get(9).and_then(|s| s.parse().ok()).unwrap_or(0.0), + profit: cells.get(10).and_then(|s| s.parse().ok()).unwrap_or(0.0), + balance: cells.get(11).and_then(|s| s.parse().ok()).unwrap_or(0.0), + comment: cells.get(12).cloned().unwrap_or_default(), + magic: cells.get(13).cloned(), }; deals.push(deal); @@ -177,25 +190,43 @@ impl ReportExtractor { let row_re = regex::Regex::new(r"]*>(.*?)") .map_err(|e| anyhow!("Regex error: {}", e))?; - + let cell_re = regex::Regex::new(r"]*>.*?]*>(.*?).*?") .map_err(|e| anyhow!("Regex error: {}", e))?; for row_caps in row_re.captures_iter(text) { let row = row_caps.get(1).map(|m| m.as_str()).unwrap_or(""); - - let cells: Vec = cell_re.captures_iter(row) + + let cells: Vec = cell_re + .captures_iter(row) .filter_map(|cap| cap.get(1)) .map(|m| Self::strip_tags(m.as_str()).replace(',', "")) .collect(); if !header_found { let row_str = cells.join("").to_lowercase(); - if row_str.contains("time") || row_str.contains("type") || row_str.contains("volume") { + if row_str.contains("time") + || row_str.contains("type") + || row_str.contains("volume") + { header_found = true; for (i, h) in cells.iter().enumerate() { let h_lower = h.to_lowercase().trim().to_string(); - let deal_columns = ["time", "deal", "symbol", "type", "entry", "volume", "price", "order", "commission", "swap", "profit", "balance", "comment"]; + let deal_columns = [ + "time", + "deal", + "symbol", + "type", + "entry", + "volume", + "price", + "order", + "commission", + "swap", + "profit", + "balance", + "comment", + ]; for col in &deal_columns { if h_lower.contains(col) || col.contains(&h_lower) { col_map.insert(i, col.to_string()); @@ -225,13 +256,31 @@ impl ReportExtractor { symbol: deal_map.get("symbol").cloned().unwrap_or_default(), deal_type: deal_map.get("type").cloned().unwrap_or_default(), entry: deal_map.get("entry").cloned().unwrap_or_default(), - volume: deal_map.get("volume").and_then(|s| s.parse().ok()).unwrap_or(0.0), - price: deal_map.get("price").and_then(|s| s.parse().ok()).unwrap_or(0.0), + volume: deal_map + .get("volume") + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0), + price: deal_map + .get("price") + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0), order: deal_map.get("order").cloned().unwrap_or_default(), - commission: deal_map.get("commission").and_then(|s| s.parse().ok()).unwrap_or(0.0), - swap: deal_map.get("swap").and_then(|s| s.parse().ok()).unwrap_or(0.0), - profit: deal_map.get("profit").and_then(|s| s.parse().ok()).unwrap_or(0.0), - balance: deal_map.get("balance").and_then(|s| s.parse().ok()).unwrap_or(0.0), + commission: deal_map + .get("commission") + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0), + swap: deal_map + .get("swap") + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0), + profit: deal_map + .get("profit") + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0), + balance: deal_map + .get("balance") + .and_then(|s| s.parse().ok()) + .unwrap_or(0.0), comment: deal_map.get("comment").cloned().unwrap_or_default(), magic: deal_map.get("magic").cloned(), }; @@ -251,10 +300,15 @@ impl ReportExtractor { fn write_deals_csv(&self, deals: &[Deal], path: &Path) -> Result<()> { let mut file = File::create(path)?; - writeln!(file, "time,deal,symbol,type,entry,volume,price,order,commission,swap,profit,balance,comment")?; - + writeln!( + file, + "time,deal,symbol,type,entry,volume,price,order,commission,swap,profit,balance,comment" + )?; + for deal in deals { - writeln!(file, "{},{},{},{},{},{},{},{},{},{},{},{},\"{}\"", + writeln!( + file, + "{},{},{},{},{},{},{},{},{},{},{},{},\"{}\"", deal.time, deal.deal, deal.symbol, @@ -270,28 +324,28 @@ impl ReportExtractor { deal.comment.replace('"', "\"\"") )?; } - + Ok(()) } fn read_text(path: &str) -> Result { let raw = fs::read(path)?; - + if raw.starts_with(&[0xFF, 0xFE]) || raw.starts_with(&[0xFE, 0xFF]) { // UTF-16 BOM let text = String::from_utf16_lossy( raw.chunks_exact(2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect::>() - .as_slice() + .as_slice(), ); return Ok(text); } - + if let Ok(text) = String::from_utf8(raw.clone()) { return Ok(text); } - + Ok(String::from_utf8_lossy(&raw).to_string()) } diff --git a/src/analytics/mod.rs b/src/analytics/mod.rs index d7007e2..9627032 100644 --- a/src/analytics/mod.rs +++ b/src/analytics/mod.rs @@ -1,5 +1,5 @@ -pub mod extract; pub mod analyze; +pub mod extract; -pub use extract::ReportExtractor; pub use analyze::DealAnalyzer; +pub use extract::ReportExtractor; diff --git a/src/compile/mql_compiler.rs b/src/compile/mql_compiler.rs index 9be6db4..f4cf7b6 100644 --- a/src/compile/mql_compiler.rs +++ b/src/compile/mql_compiler.rs @@ -30,28 +30,42 @@ impl MqlCompiler { } pub async fn compile(&self, source_path: &str) -> Result { - self.compile_with_timeout(source_path, Duration::from_secs(120)).await + self.compile_with_timeout(source_path, Duration::from_secs(120)) + .await } - pub async fn compile_with_timeout(&self, source_path: &str, timeout: Duration) -> Result { + pub async fn compile_with_timeout( + &self, + source_path: &str, + timeout: Duration, + ) -> Result { let source_path = Path::new(source_path); if !source_path.exists() { return Err(anyhow!("Source file not found: {}", source_path.display())); } - let mt5_dir = self.config.mt5_dir() + let mt5_dir = self + .config + .mt5_dir() .ok_or_else(|| anyhow!("terminal_dir not configured"))?; let metaeditor = mt5_dir.join("metaeditor64.exe"); if !metaeditor.exists() { - return Err(anyhow!("metaeditor64.exe not found at: {}", metaeditor.display())); + return Err(anyhow!( + "metaeditor64.exe not found at: {}", + metaeditor.display() + )); } - let wine_exe = self.config.wine_executable.as_ref() + let wine_exe = self + .config + .wine_executable + .as_ref() .ok_or_else(|| anyhow!("wine_executable not configured"))?; let wine_prefix = self.get_wine_prefix(&mt5_dir)?; let ea_name = source_path - .file_stem().and_then(|s| s.to_str()) + .file_stem() + .and_then(|s| s.to_str()) .ok_or_else(|| anyhow!("Invalid source file name"))?; // Stage to /tmp to avoid spaces in path (wine /compile: chokes on spaces) @@ -64,28 +78,45 @@ impl MqlCompiler { // Sync full project tree into staging dir let sync = self.sync_project_to_experts(source_path, &stage_dir)?; let staged_mq5 = &sync.dest_mq5; - tracing::info!("Staged {} file(s) to: {}", sync.files_copied, staged_mq5.display()); + tracing::info!( + "Staged {} file(s) to: {}", + sync.files_copied, + staged_mq5.display() + ); - self.run_metaeditor_with_timeout(wine_exe, &wine_prefix, &metaeditor, staged_mq5, timeout).await?; + self.run_metaeditor_with_timeout(wine_exe, &wine_prefix, &metaeditor, staged_mq5, timeout) + .await?; // /log flag (no path) writes log adjacent to source: {ea_name}.log let log_path = staged_mq5.with_extension("log"); let log_text = Self::read_log(&log_path); - tracing::info!("Compile log ({} chars):\n{}", log_text.len(), &log_text[..log_text.len().min(500)]); + tracing::info!( + "Compile log ({} chars):\n{}", + log_text.len(), + &log_text[..log_text.len().min(500)] + ); // Log format: "path : error: message" / "path : warning: message" - let errors: Vec = log_text.lines() + let errors: Vec = log_text + .lines() .filter(|l| { let low = l.to_lowercase(); - low.contains(": error:") || (low.contains("error") && !low.contains("0 error") && !low.contains("information")) + low.contains(": error:") + || (low.contains("error") + && !low.contains("0 error") + && !low.contains("information")) }) .map(|s| s.to_string()) .collect(); - let warnings: Vec = log_text.lines() + let warnings: Vec = log_text + .lines() .filter(|l| { let low = l.to_lowercase(); - low.contains(": warning:") || (low.contains("warning") && !low.contains("0 warning") && !low.contains("information")) + low.contains(": warning:") + || (low.contains("warning") + && !low.contains("0 warning") + && !low.contains("information")) }) .map(|s| s.to_string()) .collect(); @@ -93,7 +124,10 @@ impl MqlCompiler { let staged_ex5 = staged_mq5.with_extension("ex5"); if !staged_ex5.exists() { let final_errors = if errors.is_empty() { - vec![format!("Compilation failed. Log:\n{}", &log_text[log_text.len().saturating_sub(500)..])] + vec![format!( + "Compilation failed. Log:\n{}", + &log_text[log_text.len().saturating_sub(500)..] + )] } else { errors }; @@ -177,10 +211,22 @@ impl MqlCompiler { .follow_links(true) .into_iter() .filter_map(|e| e.ok()) - .filter(|e| e.path().extension().and_then(|x| x.to_str()).map(|x| x == "mq5" || x == "mqh").unwrap_or(false)) + .filter(|e| { + e.path() + .extension() + .and_then(|x| x.to_str()) + .map(|x| x == "mq5" || x == "mqh") + .unwrap_or(false) + }) .count(); - tracing::info!("Source already in Experts dir, skipping sync ({} files)", files_copied); - return Ok(SyncStats { dest_mq5, files_copied }); + tracing::info!( + "Source already in Experts dir, skipping sync ({} files)", + files_copied + ); + return Ok(SyncStats { + dest_mq5, + files_copied, + }); } if dest_dir.exists() { @@ -208,7 +254,8 @@ impl MqlCompiler { if path.extension().and_then(|e| e.to_str()) != Some("mqh") { continue; } - let relative = path.strip_prefix(project_root) + let relative = path + .strip_prefix(project_root) .map_err(|_| anyhow!("Cannot relativise {}", path.display()))?; let dest = dest_dir.join(relative); if let Some(parent) = dest.parent() { @@ -225,7 +272,10 @@ impl MqlCompiler { files_copied ); - Ok(SyncStats { dest_mq5, files_copied }) + Ok(SyncStats { + dest_mq5, + files_copied, + }) } /// Run MetaEditor to compile `source_mq5` with timeout. @@ -242,8 +292,10 @@ impl MqlCompiler { let mt5_dir = metaeditor.parent().unwrap_or(metaeditor); if wine_exe.contains("MetaTrader 5.app") { - let wine_bin = Path::new(wine_exe); - let wine_root = wine_bin.parent().and_then(|p| p.parent()) + let wine_bin = Path::new(wine_exe); + let wine_root = wine_bin + .parent() + .and_then(|p| p.parent()) .map(|p| p.to_path_buf()) .ok_or_else(|| anyhow!("Cannot derive Wine root"))?; let dyld = format!( @@ -251,8 +303,11 @@ impl MqlCompiler { wine_root.join("lib").join("external").display(), wine_root.join("lib").display(), ); - let editor_host = wine_prefix.join("drive_c") - .join("Program Files").join("MetaTrader 5").join("MetaEditor64.exe"); + let editor_host = wine_prefix + .join("drive_c") + .join("Program Files") + .join("MetaTrader 5") + .join("MetaEditor64.exe"); let script = format!( "#!/bin/sh\n\ export DYLD_FALLBACK_LIBRARY_PATH='{dyld}'\n\ @@ -260,12 +315,12 @@ impl MqlCompiler { export WINEDEBUG='-all'\n\ cd '{mt5_dir}'\n\ '{wine}' '{editor}' '/compile:{src}' /log 2>/dev/null\n", - dyld = dyld, - prefix = wine_prefix.display(), - wine = wine_exe, - editor = editor_host.display(), + dyld = dyld, + prefix = wine_prefix.display(), + wine = wine_exe, + editor = editor_host.display(), mt5_dir = mt5_dir.display(), - src = source_mq5.display(), + src = source_mq5.display(), ); let script_path = std::env::temp_dir().join("mt5_compile.sh"); fs::write(&script_path, &script)?; @@ -277,8 +332,9 @@ impl MqlCompiler { let compile_future = tokio::process::Command::new("/bin/sh") .arg(&script_path) .output(); - let result = tokio_timeout(timeout, compile_future).await - .map_err(|_| anyhow!("Compilation timed out after {} seconds", timeout.as_secs()))?; + let result = tokio_timeout(timeout, compile_future).await.map_err(|_| { + anyhow!("Compilation timed out after {} seconds", timeout.as_secs()) + })?; result?; } else { let compile_future = tokio::process::Command::new(wine_exe) @@ -289,17 +345,21 @@ impl MqlCompiler { .env("WINEDEBUG", "-all") .current_dir(mt5_dir) .output(); - let result = tokio_timeout(timeout, compile_future).await - .map_err(|_| anyhow!("Compilation timed out after {} seconds", timeout.as_secs()))?; + let result = tokio_timeout(timeout, compile_future).await.map_err(|_| { + anyhow!("Compilation timed out after {} seconds", timeout.as_secs()) + })?; result?; } Ok(()) } fn read_log(log_path: &Path) -> String { - let Ok(raw) = fs::read(log_path) else { return String::new() }; + let Ok(raw) = fs::read(log_path) else { + return String::new(); + }; if raw.starts_with(&[0xFF, 0xFE]) { - raw[2..].chunks_exact(2) + raw[2..] + .chunks_exact(2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .filter_map(|c| char::from_u32(c as u32)) .collect() diff --git a/src/main.rs b/src/main.rs index ee6944e..fedf24b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,10 +15,10 @@ use std::io::{stdout, Write}; use std::time::Instant; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpListener; -use tracing::{info, error}; +use tracing::{error, info}; use crate::models::Config; -use crate::pipeline::backtest::{BacktestPipeline, BacktestParams}; +use crate::pipeline::backtest::{BacktestParams, BacktestPipeline}; #[derive(Parser)] #[command(name = "mt5-quant")] @@ -27,19 +27,19 @@ struct Cli { /// Run as stdio MCP server (default) #[arg(short, long, default_value = "false")] stdio: bool, - + /// Run on TCP port for debugging #[arg(short, long)] port: Option, - + /// Test backtest launch performance (direct Rust call, not MCP) #[arg(long)] test_launch: bool, - + /// EA name for test launch #[arg(long)] ea: Option, - + /// Startup delay for test launch (default: 10) #[arg(long)] startup_delay: Option, @@ -101,34 +101,34 @@ async fn main() -> Result<()> { tracing_subscriber::fmt() .with_writer(std::io::stderr) .init(); - + let cli = Cli::parse(); - + if cli.test_launch { run_test_launch(cli.ea, cli.startup_delay).await?; return Ok(()); } - + if let Some(port) = cli.port { run_tcp_server(port).await?; } else { run_stdio_server().await?; } - + Ok(()) } async fn run_test_launch(ea: Option, startup_delay: Option) -> Result<()> { let expert = ea.ok_or_else(|| anyhow::anyhow!("--ea is required for test launch"))?; let delay = startup_delay.unwrap_or(10); - + println!("Testing MT5 backtest launch optimizations..."); println!("=============================================="); println!("EA: {}", expert); println!("Startup delay: {}s", delay); - + let config = Config::load()?; - + let params = BacktestParams { expert: expert.clone(), symbol: "XAUUSD".to_string(), @@ -150,9 +150,9 @@ async fn run_test_launch(ea: Option, startup_delay: Option) -> Resu startup_delay_secs: delay, inactivity_kill_secs: None, }; - + let pipeline = BacktestPipeline::new(config); - + println!("\nLaunching backtest..."); let start = Instant::now(); match pipeline.launch_backtest(params).await { @@ -168,20 +168,21 @@ async fn run_test_launch(ea: Option, startup_delay: Option) -> Resu println!("✗ Launch failed after {:.2}s: {}", elapsed.as_secs_f64(), e); } } - + println!("\n=============================================="); println!("Test complete."); - + Ok(()) } async fn run_stdio_server() -> Result<()> { info!("Starting MT5-Quant MCP server on stdio"); - + let server = std::sync::Arc::new(mcp_server::McpServer::new()); - let (notification_tx, mut notification_rx) = tokio::sync::mpsc::unbounded_channel::(); + let (notification_tx, mut notification_rx) = + tokio::sync::mpsc::unbounded_channel::(); server.set_notification_sender(notification_tx).await; - + // Spawn notification sender task tokio::spawn(async move { while let Some(notification) = notification_rx.recv().await { @@ -194,10 +195,10 @@ async fn run_stdio_server() -> Result<()> { let _ = stdout().flush(); } }); - + let mut reader = BufReader::new(tokio::io::stdin()); let mut line = String::new(); - + loop { line.clear(); match reader.read_line(&mut line).await { @@ -207,7 +208,7 @@ async fn run_stdio_server() -> Result<()> { if line.is_empty() { continue; } - + let server_clone = server.clone(); match serde_json::from_str::(line) { Ok(request) => { @@ -245,20 +246,20 @@ async fn run_stdio_server() -> Result<()> { } } } - + Ok(()) } async fn run_tcp_server(port: u16) -> Result<()> { info!("Starting MT5-Quant MCP server on TCP port {}", port); - + let listener = TcpListener::bind(format!("127.0.0.1:{}", port)).await?; info!("Listening on 127.0.0.1:{}", port); - + loop { let (socket, addr) = listener.accept().await?; info!("New connection from {}", addr); - + tokio::spawn(async move { if let Err(e) = handle_connection(socket).await { error!("Connection error: {}", e); @@ -272,7 +273,7 @@ async fn handle_connection(socket: tokio::net::TcpStream) -> Result<()> { let mut reader = BufReader::new(reader); let mut line = String::new(); let server = mcp_server::McpServer::new(); - + loop { line.clear(); match reader.read_line(&mut line).await { @@ -282,7 +283,7 @@ async fn handle_connection(socket: tokio::net::TcpStream) -> Result<()> { if line.is_empty() { continue; } - + match serde_json::from_str::(line) { Ok(request) => { if request.id.is_none() { @@ -304,6 +305,6 @@ async fn handle_connection(socket: tokio::net::TcpStream) -> Result<()> { } } } - + Ok(()) } diff --git a/src/mcp_server.rs b/src/mcp_server.rs index 91ee386..c14971c 100644 --- a/src/mcp_server.rs +++ b/src/mcp_server.rs @@ -2,7 +2,9 @@ use serde_json::{json, Value}; use std::sync::Arc; use tokio::sync::{mpsc, Mutex}; -use crate::{models::Config as ModelsConfig, tools::ToolHandler, McpError, McpRequest, McpResponse}; +use crate::{ + models::Config as ModelsConfig, tools::ToolHandler, McpError, McpRequest, McpResponse, +}; #[allow(dead_code)] type NotificationCallback = Arc; @@ -43,7 +45,7 @@ impl McpServer { pub async fn set_notification_sender(&self, tx: mpsc::UnboundedSender) { let mut guard = self.notification_tx.lock().await; *guard = Some(tx.clone()); - + // Update tool handler with notification callback let tx_clone = tx.clone(); let callback = Arc::new(move |method: &str, params: serde_json::Value| { @@ -52,10 +54,10 @@ impl McpServer { params, }); }); - + let config = ModelsConfig::load().unwrap_or_default(); let new_handler = ToolHandler::with_notification_callback(config, callback); - + let mut handler_guard = self.tool_handler.lock().await; *handler_guard = Some(new_handler); } @@ -69,42 +71,47 @@ impl McpServer { /// Run verify_setup in background - non blocking fn spawn_auto_verify(&self) { let result_arc = self.auto_verify_result.clone(); - + tokio::spawn(async move { // Get config let config = ModelsConfig::load().unwrap_or_default(); let config_path = ModelsConfig::writable_config_path(); - + // Quick async file checks let config_exists = tokio::task::spawn_blocking({ let path = config_path.clone(); move || path.exists() - }).await.unwrap_or(false); - + }) + .await + .unwrap_or(false); + let wine_ok = if let Some(wine) = &config.wine_executable { let wine = wine.clone(); - tokio::task::spawn_blocking(move || { - std::path::Path::new(&wine).exists() - }).await.unwrap_or(false) + tokio::task::spawn_blocking(move || std::path::Path::new(&wine).exists()) + .await + .unwrap_or(false) } else { false }; - + let term_ok = if let Some(term) = &config.terminal_dir { let term = term.clone(); - tokio::task::spawn_blocking(move || { - std::path::Path::new(&term).is_dir() - }).await.unwrap_or(false) + tokio::task::spawn_blocking(move || std::path::Path::new(&term).is_dir()) + .await + .unwrap_or(false) } else { false }; - + let all_ok = config_exists && wine_ok && term_ok; - + let hint = if all_ok { "Environment fully configured and ready".to_string() } else if !config_exists { - format!("Auto-discovery will run on first request. Config will be written to {}", config_path.display()) + format!( + "Auto-discovery will run on first request. Config will be written to {}", + config_path.display() + ) } else if !wine_ok { "Wine/CrossOver not found - required for MT5 execution".to_string() } else if !term_ok { @@ -112,19 +119,19 @@ impl McpServer { } else { "Fix missing paths in config".to_string() }; - + let result = AutoVerifyResult { all_ok, hint, config_path: config_path.to_string_lossy().to_string(), }; - + // Store result let mut guard = result_arc.lock().await; *guard = Some(result); }); } - + /// Get current verify status (may be loading if called immediately after init) #[allow(dead_code)] async fn get_verify_status(&self) -> (Option, String) { @@ -150,24 +157,15 @@ impl McpServer { pub async fn handle_request(&self, request: McpRequest) -> McpResponse { match request.method.as_str() { "initialize" => { - // Protocol version negotiation: client sends desired version - let client_version = request.params.as_ref() - .and_then(|p| p.get("protocolVersion")) - .and_then(|v| v.as_str()) - .unwrap_or("2024-11-05"); - - // Server selects version (latest supported that client also supports) - let negotiated_version = if client_version.starts_with("2025-") { - "2024-11-05" // Fall back to stable version - } else { - "2024-11-05" - }; - + // Protocol version negotiation: server selects the stable protocol + // version regardless of which version the client requests. + let negotiated_version = "2024-11-05"; + // Start background verify (non-blocking) self.spawn_auto_verify(); - + *self.initialized.lock().await = true; - + // Return immediately with fast status let server_info = json!({ "name": "MT5-Quant", @@ -176,7 +174,7 @@ impl McpServer { "hint": "Auto-verification running... Use verify_setup tool for detailed status", } }); - + McpResponse { jsonrpc: "2.0".to_string(), id: request.id, @@ -202,12 +200,13 @@ impl McpServer { result: None, error: Some(McpError { code: -32600, - message: "Received request before initialization was complete".to_string(), + message: "Received request before initialization was complete" + .to_string(), data: None, }), }; } - + McpResponse { jsonrpc: "2.0".to_string(), id: request.id, @@ -224,16 +223,17 @@ impl McpServer { result: None, error: Some(McpError { code: -32600, - message: "Received request before initialization was complete".to_string(), + message: "Received request before initialization was complete" + .to_string(), data: None, }), }; } - + if let Some(params) = request.params { if let (Some(tool_name), Some(arguments)) = ( params.get("name").and_then(|v| v.as_str()), - params.get("arguments") + params.get("arguments"), ) { let result = self.handle_tool_call(tool_name, arguments).await; McpResponse { @@ -267,18 +267,16 @@ impl McpServer { } } } - _ => { - McpResponse { - jsonrpc: "2.0".to_string(), - id: request.id, - result: None, - error: Some(McpError { - code: -32601, - message: format!("Method not found: {}", request.method), - data: None, - }), - } - } + _ => McpResponse { + jsonrpc: "2.0".to_string(), + id: request.id, + result: None, + error: Some(McpError { + code: -32601, + message: format!("Method not found: {}", request.method), + data: None, + }), + }, } } @@ -286,24 +284,24 @@ impl McpServer { let handler_guard = self.tool_handler.lock().await; let handler = handler_guard.as_ref().cloned(); drop(handler_guard); - + match handler { - Some(h) => { - h.handle(tool_name, arguments).await.unwrap_or_else(|e| json!({ + Some(h) => h.handle(tool_name, arguments).await.unwrap_or_else(|e| { + json!({ "content": [{ "type": "text", "text": format!("Tool execution failed: {}", e) }], "isError": true - })) - } + }) + }), None => json!({ "content": [{ "type": "text", "text": "Tool handler not initialized" }], "isError": true - }) + }), } } } diff --git a/src/models/config.rs b/src/models/config.rs index 27bf596..acc2d2a 100644 --- a/src/models/config.rs +++ b/src/models/config.rs @@ -38,7 +38,7 @@ impl CurrentAccount { String::from_utf16(&u16_slice).ok()? } else { // Try UTF-8 fallback - String::from_utf8(bytes).ok()? + String::from_utf8(bytes).ok()? }; let mut login = None; @@ -46,10 +46,11 @@ impl CurrentAccount { for line in content.lines() { // Remove null bytes and control characters but keep printable ASCII and valid Unicode - let cleaned: String = line.chars() + let cleaned: String = line + .chars() .filter(|c| *c != '\0' && !c.is_control()) .collect(); - + let trimmed = cleaned.trim(); if trimmed.starts_with("Login=") { let val = trimmed.strip_prefix("Login=").map(|s| s.trim().to_string()); @@ -59,7 +60,9 @@ impl CurrentAccount { } } } else if trimmed.starts_with("Server=") { - let val = trimmed.strip_prefix("Server=").map(|s| s.trim().to_string()); + let val = trimmed + .strip_prefix("Server=") + .map(|s| s.trim().to_string()); if let Some(v) = val { if !v.is_empty() { server = Some(v); @@ -69,13 +72,16 @@ impl CurrentAccount { } match (login, server) { - (Some(l), Some(s)) => Some(Self { login: l, server: s }), + (Some(l), Some(s)) => Some(Self { + login: l, + server: s, + }), _ => None, } } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Config { pub wine_executable: Option, pub terminal_dir: Option, @@ -102,36 +108,6 @@ pub struct Config { pub project_dir: Option, } -impl Default for Config { - fn default() -> Self { - Self { - wine_executable: None, - terminal_dir: None, - experts_dir: None, - indicators_dir: None, - scripts_dir: None, - tester_profiles_dir: None, - tester_cache_dir: None, - display_mode: None, - backtest_symbol: None, - backtest_deposit: None, - backtest_currency: None, - backtest_leverage: None, - backtest_model: None, - backtest_timeframe: None, - backtest_timeout: None, - opt_log_dir: None, - opt_min_agents: None, - opt_max_agents: None, - reports_dir: None, - backtest_login: None, - backtest_server: None, - backtest_password: None, - project_dir: None, - } - } -} - impl Config { pub fn load() -> Result { let config_path = Self::writable_config_path(); @@ -177,14 +153,17 @@ impl Config { } // 3. Check standard location - let standard_config = Self::standard_config_dir().join("config").join("mt5-quant.yaml"); + let standard_config = Self::standard_config_dir() + .join("config") + .join("mt5-quant.yaml"); if standard_config.exists() { return standard_config; } // 4. Development fallback - use project directory let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); - let dev_config = manifest_dir.parent() + let dev_config = manifest_dir + .parent() .unwrap_or(manifest_dir) .join("config") .join("mt5-quant.yaml"); @@ -193,40 +172,52 @@ impl Config { } // 5. Fall back to standard location (will be created if not exists) - Self::standard_config_dir().join("config").join("mt5-quant.yaml") + Self::standard_config_dir() + .join("config") + .join("mt5-quant.yaml") } // ── Auto-discovery ──────────────────────────────────────────────────────── pub fn auto_discover() -> Self { let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); - let mut cfg = Config::default(); - - // 1. Find Wine executable ------------------------------------------- - cfg.wine_executable = Self::find_wine(&home); + let mut cfg = Config { + wine_executable: Self::find_wine(&home), + ..Default::default() + }; // 2. Find MT5 terminal directory ------------------------------------ if let Some(mt5_dir) = Self::find_mt5_dir(&home) { cfg.experts_dir = Some( - mt5_dir.join("MQL5").join("Experts") - .to_string_lossy().to_string(), + mt5_dir + .join("MQL5") + .join("Experts") + .to_string_lossy() + .to_string(), ); cfg.indicators_dir = Some( - mt5_dir.join("MQL5").join("Indicators") - .to_string_lossy().to_string(), + mt5_dir + .join("MQL5") + .join("Indicators") + .to_string_lossy() + .to_string(), ); cfg.scripts_dir = Some( - mt5_dir.join("MQL5").join("Scripts") - .to_string_lossy().to_string(), + mt5_dir + .join("MQL5") + .join("Scripts") + .to_string_lossy() + .to_string(), ); cfg.tester_profiles_dir = Some( - mt5_dir.join("MQL5").join("Profiles").join("Tester") - .to_string_lossy().to_string(), - ); - cfg.tester_cache_dir = Some( - mt5_dir.join("Tester") - .to_string_lossy().to_string(), + mt5_dir + .join("MQL5") + .join("Profiles") + .join("Tester") + .to_string_lossy() + .to_string(), ); + cfg.tester_cache_dir = Some(mt5_dir.join("Tester").to_string_lossy().to_string()); cfg.terminal_dir = Some(mt5_dir.to_string_lossy().to_string()); } @@ -234,16 +225,16 @@ impl Config { cfg.display_mode = Some(Self::detect_display_mode()); // 4. Sensible backtest defaults ------------------------------------ - cfg.backtest_symbol = Some("XAUUSD".into()); - cfg.backtest_deposit = Some(10000); + cfg.backtest_symbol = Some("XAUUSD".into()); + cfg.backtest_deposit = Some(10000); cfg.backtest_currency = Some("USD".into()); cfg.backtest_leverage = Some(500); - cfg.backtest_model = Some(0); + cfg.backtest_model = Some(0); cfg.backtest_timeframe = Some("M5".into()); - cfg.backtest_timeout = Some(900); - cfg.opt_log_dir = Some("/tmp".into()); - cfg.opt_min_agents = Some(1); - cfg.opt_max_agents = Some(20); + cfg.backtest_timeout = Some(900); + cfg.opt_log_dir = Some("/tmp".into()); + cfg.opt_min_agents = Some(1); + cfg.opt_max_agents = Some(20); cfg } @@ -255,7 +246,9 @@ impl Config { PathBuf::from("/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"), // macOS: CrossOver (new versions may use 'wine', older ones 'wine64') home.join("Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine"), - home.join("Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine64"), + home.join( + "Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine64", + ), // macOS: Homebrew Apple Silicon (prefer 'wine', fall back to 'wine64') PathBuf::from("/opt/homebrew/bin/wine"), PathBuf::from("/opt/homebrew/bin/wine64"), @@ -266,7 +259,8 @@ impl Config { PathBuf::from("/usr/bin/wine"), PathBuf::from("/usr/bin/wine64"), ]; - candidates.iter() + candidates + .iter() .find(|p| p.exists()) .map(|p| p.to_string_lossy().to_string()) } @@ -284,8 +278,7 @@ impl Config { if bottles_root.is_dir() { if let Ok(bottles) = fs::read_dir(&bottles_root) { for bottle in bottles.filter_map(|e| e.ok()) { - let mt5 = bottle.path() - .join("drive_c/Program Files/MetaTrader 5"); + let mt5 = bottle.path().join("drive_c/Program Files/MetaTrader 5"); candidates.push(mt5); } } @@ -344,22 +337,22 @@ impl Config { opt_log_dir: {opt_log}\n\ opt_min_agents: {opt_agents}\n\ opt_max_agents: {max_agents}\n", - wine = s(&self.wine_executable), - term = s(&self.terminal_dir), - exp = s(&self.experts_dir), - ind = s(&self.indicators_dir), - scr = s(&self.scripts_dir), - prof = s(&self.tester_profiles_dir), - cache = s(&self.tester_cache_dir), - disp = s(&self.display_mode), - sym = s(&self.backtest_symbol), - dep = u(self.backtest_deposit), - cur = s(&self.backtest_currency), - lev = u(self.backtest_leverage), - mdl = u(self.backtest_model), - tf = s(&self.backtest_timeframe), - to = u(self.backtest_timeout), - opt_log = s(&self.opt_log_dir), + wine = s(&self.wine_executable), + term = s(&self.terminal_dir), + exp = s(&self.experts_dir), + ind = s(&self.indicators_dir), + scr = s(&self.scripts_dir), + prof = s(&self.tester_profiles_dir), + cache = s(&self.tester_cache_dir), + disp = s(&self.display_mode), + sym = s(&self.backtest_symbol), + dep = u(self.backtest_deposit), + cur = s(&self.backtest_currency), + lev = u(self.backtest_leverage), + mdl = u(self.backtest_model), + tf = s(&self.backtest_timeframe), + to = u(self.backtest_timeout), + opt_log = s(&self.opt_log_dir), opt_agents = u(self.opt_min_agents), max_agents = u(self.opt_max_agents), ); @@ -382,7 +375,8 @@ impl Config { } if let Some((key, value)) = line.split_once(':') { let key = key.trim().to_string(); - let value = value.trim() + let value = value + .trim() .trim_matches('"') .trim_matches('\'') .to_string(); @@ -393,29 +387,29 @@ impl Config { } Ok(Config { - wine_executable: map.get("wine_executable").cloned(), - terminal_dir: map.get("terminal_dir").cloned(), - experts_dir: map.get("experts_dir").cloned(), - indicators_dir: map.get("indicators_dir").cloned(), - scripts_dir: map.get("scripts_dir").cloned(), - tester_profiles_dir: map.get("tester_profiles_dir").cloned(), - tester_cache_dir: map.get("tester_cache_dir").cloned(), - display_mode: map.get("display_mode").cloned(), - backtest_symbol: map.get("backtest_symbol").cloned(), - backtest_deposit: map.get("backtest_deposit").and_then(|s| s.parse().ok()), - backtest_currency: map.get("backtest_currency").cloned(), - backtest_leverage: map.get("backtest_leverage").and_then(|s| s.parse().ok()), - backtest_model: map.get("backtest_model").and_then(|s| s.parse().ok()), - backtest_timeframe: map.get("backtest_timeframe").cloned(), - backtest_timeout: map.get("backtest_timeout").and_then(|s| s.parse().ok()), - opt_log_dir: map.get("opt_log_dir").cloned(), - opt_min_agents: map.get("opt_min_agents").and_then(|s| s.parse().ok()), - opt_max_agents: map.get("opt_max_agents").and_then(|s| s.parse().ok()), - reports_dir: map.get("reports_dir").cloned(), - backtest_login: map.get("backtest_login").cloned(), - backtest_server: map.get("backtest_server").cloned(), - backtest_password: map.get("backtest_password").cloned(), - project_dir: map.get("project_dir").cloned(), + wine_executable: map.get("wine_executable").cloned(), + terminal_dir: map.get("terminal_dir").cloned(), + experts_dir: map.get("experts_dir").cloned(), + indicators_dir: map.get("indicators_dir").cloned(), + scripts_dir: map.get("scripts_dir").cloned(), + tester_profiles_dir: map.get("tester_profiles_dir").cloned(), + tester_cache_dir: map.get("tester_cache_dir").cloned(), + display_mode: map.get("display_mode").cloned(), + backtest_symbol: map.get("backtest_symbol").cloned(), + backtest_deposit: map.get("backtest_deposit").and_then(|s| s.parse().ok()), + backtest_currency: map.get("backtest_currency").cloned(), + backtest_leverage: map.get("backtest_leverage").and_then(|s| s.parse().ok()), + backtest_model: map.get("backtest_model").and_then(|s| s.parse().ok()), + backtest_timeframe: map.get("backtest_timeframe").cloned(), + backtest_timeout: map.get("backtest_timeout").and_then(|s| s.parse().ok()), + opt_log_dir: map.get("opt_log_dir").cloned(), + opt_min_agents: map.get("opt_min_agents").and_then(|s| s.parse().ok()), + opt_max_agents: map.get("opt_max_agents").and_then(|s| s.parse().ok()), + reports_dir: map.get("reports_dir").cloned(), + backtest_login: map.get("backtest_login").cloned(), + backtest_server: map.get("backtest_server").cloned(), + backtest_password: map.get("backtest_password").cloned(), + project_dir: map.get("project_dir").cloned(), }) } @@ -423,28 +417,43 @@ impl Config { pub fn get(&self, key: &str) -> String { match key { - "wine_executable" => self.wine_executable.clone().unwrap_or_default(), - "terminal_dir" => self.terminal_dir.clone().unwrap_or_default(), - "experts_dir" => self.experts_dir.clone().unwrap_or_default(), - "tester_profiles_dir"=> self.tester_profiles_dir.clone().unwrap_or_default(), - "tester_cache_dir" => self.tester_cache_dir.clone().unwrap_or_default(), - "display_mode" => self.display_mode.clone().unwrap_or_else(|| "auto".to_string()), - "backtest_symbol" => self.backtest_symbol.clone().unwrap_or_default(), - "backtest_deposit" => self.backtest_deposit.unwrap_or(10000).to_string(), - "backtest_currency" => self.backtest_currency.clone().unwrap_or_else(|| "USD".to_string()), - "backtest_leverage" => self.backtest_leverage.unwrap_or(500).to_string(), - "backtest_model" => self.backtest_model.unwrap_or(0).to_string(), - "backtest_timeframe" => self.backtest_timeframe.clone().unwrap_or_else(|| "M5".to_string()), - "backtest_timeout" => self.backtest_timeout.unwrap_or(900).to_string(), - "opt_log_dir" => self.opt_log_dir.clone().unwrap_or_else(|| "/tmp".to_string()), - "opt_min_agents" => self.opt_min_agents.unwrap_or(1).to_string(), - "opt_max_agents" => self.opt_max_agents.unwrap_or(0).to_string(), - "reports_dir" => self.reports_dir.clone().unwrap_or_else(|| "reports".to_string()), - "backtest_login" => self.backtest_login.clone().unwrap_or_default(), - "backtest_server" => self.backtest_server.clone().unwrap_or_default(), - "backtest_password" => self.backtest_password.clone().unwrap_or_default(), - "project_dir" => self.project_dir.clone().unwrap_or_default(), - _ => String::new(), + "wine_executable" => self.wine_executable.clone().unwrap_or_default(), + "terminal_dir" => self.terminal_dir.clone().unwrap_or_default(), + "experts_dir" => self.experts_dir.clone().unwrap_or_default(), + "tester_profiles_dir" => self.tester_profiles_dir.clone().unwrap_or_default(), + "tester_cache_dir" => self.tester_cache_dir.clone().unwrap_or_default(), + "display_mode" => self + .display_mode + .clone() + .unwrap_or_else(|| "auto".to_string()), + "backtest_symbol" => self.backtest_symbol.clone().unwrap_or_default(), + "backtest_deposit" => self.backtest_deposit.unwrap_or(10000).to_string(), + "backtest_currency" => self + .backtest_currency + .clone() + .unwrap_or_else(|| "USD".to_string()), + "backtest_leverage" => self.backtest_leverage.unwrap_or(500).to_string(), + "backtest_model" => self.backtest_model.unwrap_or(0).to_string(), + "backtest_timeframe" => self + .backtest_timeframe + .clone() + .unwrap_or_else(|| "M5".to_string()), + "backtest_timeout" => self.backtest_timeout.unwrap_or(900).to_string(), + "opt_log_dir" => self + .opt_log_dir + .clone() + .unwrap_or_else(|| "/tmp".to_string()), + "opt_min_agents" => self.opt_min_agents.unwrap_or(1).to_string(), + "opt_max_agents" => self.opt_max_agents.unwrap_or(0).to_string(), + "reports_dir" => self + .reports_dir + .clone() + .unwrap_or_else(|| "reports".to_string()), + "backtest_login" => self.backtest_login.clone().unwrap_or_default(), + "backtest_server" => self.backtest_server.clone().unwrap_or_default(), + "backtest_password" => self.backtest_password.clone().unwrap_or_default(), + "project_dir" => self.project_dir.clone().unwrap_or_default(), + _ => String::new(), } } @@ -514,7 +523,9 @@ impl Config { } pub fn mt5_dir(&self) -> Option { - self.terminal_dir.as_ref().map(|d| Path::new(d).to_path_buf()) + self.terminal_dir + .as_ref() + .map(|d| Path::new(d).to_path_buf()) } /// Scan the tester's own history store for symbols with downloaded data. @@ -619,19 +630,25 @@ impl Config { // 3. Cent-suffix normalisation: build a normalised "base" for both sides // Strip known cent suffixes: `.cent`, `c` (trailing, uppercase only), `.c` fn base_ticker(sym: &str) -> &str { - let s = sym.trim_end_matches(".cent") - .trim_end_matches(".c"); + let s = sym.trim_end_matches(".cent").trim_end_matches(".c"); // Strip trailing lowercase 'c' only when the rest is all-uppercase // (so "XAUUSDc" → "XAUUSD", but "Misc" stays "Misc") - if s.ends_with('c') && s[..s.len()-1].chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) { - &s[..s.len()-1] + if s.ends_with('c') + && s[..s.len() - 1] + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) + { + &s[..s.len() - 1] } else { s } } let req_base = base_ticker(requested).to_lowercase(); - if let Some(s) = available.iter().find(|s| base_ticker(s).to_lowercase() == req_base) { + if let Some(s) = available + .iter() + .find(|s| base_ticker(s).to_lowercase() == req_base) + { return Some(s.as_str()); } @@ -648,7 +665,8 @@ impl Config { /// Get the currently active MT5 account from common.ini pub fn current_account(&self) -> Option { - self.mt5_dir().and_then(|d| CurrentAccount::from_common_ini(&d)) + self.mt5_dir() + .and_then(|d| CurrentAccount::from_common_ini(&d)) } /// Discover symbols for the currently active account/server only diff --git a/src/models/metrics.rs b/src/models/metrics.rs index aa7daa0..aec2961 100644 --- a/src/models/metrics.rs +++ b/src/models/metrics.rs @@ -18,31 +18,56 @@ impl Metrics { let mut m = Metrics::default(); let patterns = [ - ("net_profit", r"Net\s+Profit[^<]*\s*]*>\s*([-\d\s.,]+)"), - ("profit_factor", r"Profit\s+Factor[^<]*\s*]*>\s*([-\d\s.,]+)"), - ("max_dd_pct", r"Equity\s+Drawdown\s+Maximal[^<]*\s*]*>\s*[^(]*\(([\d.,]+)%\)"), - ("sharpe_ratio", r"Sharpe\s+Ratio[^<]*\s*]*>\s*([-\d\s.,]+)"), - ("total_trades", r"Total\s+Trades[^<]*\s*]*>\s*([-\d\s.,]+)"), - ("recovery_factor", r"Recovery\s+Factor[^<]*\s*]*>\s*([-\d\s.,]+)"), - ("win_rate_pct", r"Profit\s+Trades\s+\(%[^<]*\s*]*>\s*[^(]*\(([\d.,]+)%\)"), - ("gross_profit", r"Gross\s+Profit[^<]*\s*]*>\s*([-\d\s.,]+)"), - ("gross_loss", r"Gross\s+Loss[^<]*\s*]*>\s*([-\d\s.,]+)"), + ( + "net_profit", + r"Net\s+Profit[^<]*\s*]*>\s*([-\d\s.,]+)", + ), + ( + "profit_factor", + r"Profit\s+Factor[^<]*\s*]*>\s*([-\d\s.,]+)", + ), + ( + "max_dd_pct", + r"Equity\s+Drawdown\s+Maximal[^<]*\s*]*>\s*[^(]*\(([\d.,]+)%\)", + ), + ( + "sharpe_ratio", + r"Sharpe\s+Ratio[^<]*\s*]*>\s*([-\d\s.,]+)", + ), + ( + "total_trades", + r"Total\s+Trades[^<]*\s*]*>\s*([-\d\s.,]+)", + ), + ( + "recovery_factor", + r"Recovery\s+Factor[^<]*\s*]*>\s*([-\d\s.,]+)", + ), + ( + "win_rate_pct", + r"Profit\s+Trades\s+\(%[^<]*\s*]*>\s*[^(]*\(([\d.,]+)%\)", + ), + ( + "gross_profit", + r"Gross\s+Profit[^<]*\s*]*>\s*([-\d\s.,]+)", + ), + ( + "gross_loss", + r"Gross\s+Loss[^<]*\s*]*>\s*([-\d\s.,]+)", + ), ]; for (key, pattern) in &patterns { if let Ok(regex) = regex::Regex::new(pattern) { if let Some(captures) = regex.captures(text) { if let Some(val_str) = captures.get(1) { - let val = val_str.as_str() - .replace(' ', "") - .replace(',', ""); - + let val = val_str.as_str().replace([' ', ','], ""); + match *key { "net_profit" => m.net_profit = val.parse().unwrap_or(0.0), "profit_factor" => m.profit_factor = val.parse().unwrap_or(0.0), "max_dd_pct" => m.max_dd_pct = val.parse().unwrap_or(0.0), "sharpe_ratio" => m.sharpe_ratio = val.parse().unwrap_or(0.0), - "total_trades" => m.total_trades = val.parse().unwrap_or(0) as i32, + "total_trades" => m.total_trades = val.parse().unwrap_or(0), "recovery_factor" => m.recovery_factor = val.parse().unwrap_or(0.0), "win_rate_pct" => m.win_rate_pct = val.parse().unwrap_or(0.0), "gross_profit" => m.gross_profit = val.parse().unwrap_or(0.0), diff --git a/src/optimization/optimizer.rs b/src/optimization/optimizer.rs index 0492926..6ba0122 100644 --- a/src/optimization/optimizer.rs +++ b/src/optimization/optimizer.rs @@ -11,7 +11,7 @@ use crate::optimization::OptimizationParser; /// MT5 .set and .ini files are typically UTF-16LE with BOM (0xFF 0xFE). fn read_file_as_utf8(path: &Path) -> Result { let bytes = fs::read(path)?; - + // Check for UTF-16LE BOM (0xFF 0xFE) if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { // UTF-16LE with BOM - skip the 2-byte BOM and decode @@ -19,12 +19,10 @@ fn read_file_as_utf8(path: &Path) -> Result { .chunks_exact(2) .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) .collect(); - String::from_utf16(&utf16_data) - .map_err(|e| anyhow!("Failed to decode UTF-16LE: {}", e)) + String::from_utf16(&utf16_data).map_err(|e| anyhow!("Failed to decode UTF-16LE: {}", e)) } else { // Try UTF-8 - String::from_utf8(bytes) - .map_err(|e| anyhow!("Failed to decode as UTF-8: {}", e)) + String::from_utf8(bytes).map_err(|e| anyhow!("Failed to decode as UTF-8: {}", e)) } } @@ -106,38 +104,57 @@ impl OptimizationRunner { let log_file = PathBuf::from(format!("/tmp/mt5opt_{}.log", timestamp)); // Calculate agent count: 75% of available CPUs, or configured value - let cpu_count = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + let cpu_count = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1); let default_agents = ((cpu_count as f64 * 0.75).ceil() as u32).max(1); let max_agents = self.config.opt_max_agents.unwrap_or(default_agents).max(1); // Count combinations - let combinations = self.count_combinations(¶ms.set_file) + let combinations = self + .count_combinations(¶ms.set_file) .map_err(|e| anyhow!("count_combinations failed: {}", e))?; // Get paths - let mt5_dir = self.config.terminal_dir.as_ref() + let mt5_dir = self + .config + .terminal_dir + .as_ref() .ok_or_else(|| anyhow!("terminal_dir not configured"))?; - let wine_exe = self.config.wine_executable.as_ref() + let wine_exe = self + .config + .wine_executable + .as_ref() .ok_or_else(|| anyhow!("wine_executable not configured"))?; - + // Resolve the .set filename MT5 will actually load: basename of set_file, // else "{expert}.set". Must match the name told to MT5 below (ExpertParameters). - let set_param = if !params.set_file.is_empty() && params.set_file != format!("{}.set", params.expert) { - std::path::Path::new(¶ms.set_file).file_name() - .and_then(|n| n.to_str()) - .unwrap_or(&format!("{}.set", params.expert)) - .to_string() - } else { - format!("{}.set", params.expert) - }; + let set_param = + if !params.set_file.is_empty() && params.set_file != format!("{}.set", params.expert) { + std::path::Path::new(¶ms.set_file) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&format!("{}.set", params.expert)) + .to_string() + } else { + format!("{}.set", params.expert) + }; // Write .set file as UTF-16LE with BOM directly to MT5 tester directory let wine_prefix_dir = self.get_wine_prefix_dir(mt5_dir)?; - let tester_dir = wine_prefix_dir.join("drive_c/Program Files/MetaTrader 5/MQL5/Profiles/Tester"); - fs::create_dir_all(&tester_dir).map_err(|e| anyhow!("create_dir_all({}) failed: {}", tester_dir.display(), e))?; + let tester_dir = + wine_prefix_dir.join("drive_c/Program Files/MetaTrader 5/MQL5/Profiles/Tester"); + fs::create_dir_all(&tester_dir) + .map_err(|e| anyhow!("create_dir_all({}) failed: {}", tester_dir.display(), e))?; let dst_set_file = tester_dir.join(&set_param); self.write_utf16le_set(¶ms.set_file, &dst_set_file) - .map_err(|e| anyhow!("write_utf16le_set({}) failed: {}", dst_set_file.display(), e))?; + .map_err(|e| { + anyhow!( + "write_utf16le_set({}) failed: {}", + dst_set_file.display(), + e + ) + })?; // Reset OptMode in terminal.ini // Patch terminal.ini [Tester] section with optimization params (primary mechanism) @@ -152,7 +169,9 @@ impl OptimizationRunner { String::new() }; let expert_path = if let Some(experts_dir) = &self.config.experts_dir { - let nested = Path::new(experts_dir).join(¶ms.expert).join(format!("{}.mq5", params.expert)); + let nested = Path::new(experts_dir) + .join(¶ms.expert) + .join(format!("{}.mq5", params.expert)); if nested.exists() { format!("Experts\\{}\\{}.ex5", params.expert, params.expert) } else { @@ -181,9 +200,16 @@ impl OptimizationRunner { Visual=0\n\ Report=..\\..\\mt5mcp_opt_report.htm\n\ ReplaceReport=1\n\ - ShutdownTerminal=1", - expert_path, set_param, params.symbol, max_agents, - params.from_date, params.to_date, params.deposit, params.currency, params.leverage, + ShutdownTerminal=1", + expert_path, + set_param, + params.symbol, + max_agents, + params.from_date, + params.to_date, + params.deposit, + params.currency, + params.leverage, ); if let Some(mp) = params.max_passes { tester_section.push_str(&format!("\nMaxPass={}", mp)); @@ -209,7 +235,7 @@ impl OptimizationRunner { if let Some(password) = &self.config.backtest_password { opt_ini.push_str(&format!("Password={}\n", password)); } - opt_ini.push_str("\n"); + opt_ini.push('\n'); } } opt_ini.push_str("[Tester]\n"); @@ -243,12 +269,18 @@ impl OptimizationRunner { .parent() .and_then(|p| p.parent()) .ok_or_else(|| anyhow!("Cannot derive Wine root from wine_exe"))?; - let ext_libs = wine_root.join("lib").join("external"); + let ext_libs = wine_root.join("lib").join("external"); let wine_libs = wine_root.join("lib"); - let dyld = format!("{}:{}:/usr/lib:/usr/local/lib", - ext_libs.display(), wine_libs.display()); - let terminal_host = wine_prefix_dir.join("drive_c") - .join("Program Files").join("MetaTrader 5").join("terminal64.exe"); + let dyld = format!( + "{}:{}:/usr/lib:/usr/local/lib", + ext_libs.display(), + wine_libs.display() + ); + let terminal_host = wine_prefix_dir + .join("drive_c") + .join("Program Files") + .join("MetaTrader 5") + .join("terminal64.exe"); let script = format!( "#!/bin/sh\n\ @@ -282,7 +314,14 @@ impl OptimizationRunner { let pid = child.id(); // Write job metadata - self.write_job_metadata(&job_id, pid, ¶ms, &log_file, combinations, &wine_prefix_dir)?; + self.write_job_metadata( + &job_id, + pid, + ¶ms, + &log_file, + combinations, + &wine_prefix_dir, + )?; Ok(OptimizationResult { success: true, @@ -290,7 +329,10 @@ impl OptimizationRunner { pid, log_file, combinations, - message: format!("Optimization launched (pid: {}). Runs for 2-6 hours. Do NOT kill this process.", pid), + message: format!( + "Optimization launched (pid: {}). Runs for 2-6 hours. Do NOT kill this process.", + pid + ), }) } @@ -325,7 +367,7 @@ impl OptimizationRunner { fn write_utf16le_set(&self, src: &str, dst: &Path) -> Result<()> { let content = read_file_as_utf8(Path::new(src))?; - + // Create parent directory if needed if let Some(parent) = dst.parent() { fs::create_dir_all(parent)?; @@ -344,11 +386,12 @@ impl OptimizationRunner { // Write UTF-16LE with BOM let mut utf16_content: Vec = vec![0xFEFF]; // BOM utf16_content.extend(content.encode_utf16()); - - let bytes: Vec = utf16_content.iter() + + let bytes: Vec = utf16_content + .iter() .flat_map(|&c| vec![(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8]) .collect(); - + fs::write(dst, bytes)?; Ok(()) @@ -500,22 +543,37 @@ impl OptimizationRunner { match parser.parse_job(job_id) { Ok(passes) if !passes.is_empty() => { let mut sorted_by_pf = passes.clone(); - sorted_by_pf.sort_by(|a, b| b.profit_factor.partial_cmp(&a.profit_factor).unwrap()); + sorted_by_pf + .sort_by(|a, b| b.profit_factor.partial_cmp(&a.profit_factor).unwrap()); let top10: Vec<_> = sorted_by_pf.into_iter().take(10).collect(); let best_pf = parser.find_best_pass(&passes, "profit_factor"); let best_profit = parser.find_best_pass(&passes, "profit"); - let m = result.as_object_mut() + let m = result + .as_object_mut() .ok_or_else(|| anyhow!("result is not object"))?; - m.insert("status".into(), serde_json::Value::String("completed".into())); + m.insert( + "status".into(), + serde_json::Value::String("completed".into()), + ); m.insert("total_passes".into(), serde_json::json!(passes.len())); - m.insert("top_10".into(), serde_json::to_value(&top10).unwrap_or_default()); - m.insert("best_pf".into(), serde_json::to_value(best_pf).unwrap_or_default()); - m.insert("best_profit".into(), serde_json::to_value(best_profit).unwrap_or_default()); + m.insert( + "top_10".into(), + serde_json::to_value(&top10).unwrap_or_default(), + ); + m.insert( + "best_pf".into(), + serde_json::to_value(best_pf).unwrap_or_default(), + ); + m.insert( + "best_profit".into(), + serde_json::to_value(best_profit).unwrap_or_default(), + ); } _ => { - let m = result.as_object_mut() + let m = result + .as_object_mut() .ok_or_else(|| anyhow!("result is not object"))?; m.insert("status".into(), serde_json::Value::String("stopped".into())); m.insert("message".into(), serde_json::Value::String( @@ -549,27 +607,26 @@ impl OptimizationRunner { let mut jobs = Vec::new(); if jobs_dir.exists() { - for entry in fs::read_dir(jobs_dir)? { - if let Ok(entry) = entry { - let path = entry.path(); - if path.extension().map(|e| e == "json").unwrap_or(false) { - if let Ok(content) = fs::read_to_string(&path) { - if let Ok(meta) = serde_json::from_str::(&content) { - let job_id = path.file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string(); - - let pid = meta.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u32; - let is_running = self.is_process_running(pid); - - jobs.push(serde_json::json!({ - "job_id": job_id, - "expert": meta.get("expert"), - "status": if is_running { "running" } else { "stopped" }, - "started_at": meta.get("started_at"), - })); - } + for entry in fs::read_dir(jobs_dir)?.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "json").unwrap_or(false) { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(meta) = serde_json::from_str::(&content) { + let job_id = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + let pid = meta.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u32; + let is_running = self.is_process_running(pid); + + jobs.push(serde_json::json!({ + "job_id": job_id, + "expert": meta.get("expert"), + "status": if is_running { "running" } else { "stopped" }, + "started_at": meta.get("started_at"), + })); } } } diff --git a/src/optimization/parser.rs b/src/optimization/parser.rs index 0c0cbbb..be357c3 100644 --- a/src/optimization/parser.rs +++ b/src/optimization/parser.rs @@ -48,12 +48,13 @@ impl OptimizationParser { } // Fallback: derive from wine_prefix (legacy) - let wine_prefix = meta.get("wine_prefix") + let wine_prefix = meta + .get("wine_prefix") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow!("wine_prefix not in job metadata"))?; let base_path = Path::new(wine_prefix).join("drive_c/mt5mcp_opt_report"); - + // Try different extensions for ext in &[".htm", ".htm.xml", ".html"] { let candidate = base_path.with_extension(ext.trim_start_matches('.')); @@ -84,45 +85,50 @@ impl OptimizationParser { if path_str.ends_with(".xml") || path_str.ends_with(".htm.xml") { return "xml"; } - + if let Ok(header) = fs::read(path) { let header = &header[..header.len().min(512)]; - if header.windows(5).any(|w| w == b" Result { let raw = fs::read(path)?; - + // Try UTF-16 first (common for MT5 reports) if raw.len() >= 2 { if raw[0] == 0xFF && raw[1] == 0xFE { // UTF-16 LE with BOM - let u16_vec: Vec = raw[2..].chunks_exact(2) + let u16_vec: Vec = raw[2..] + .chunks_exact(2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect(); return Ok(String::from_utf16_lossy(&u16_vec)); } else if raw[0] == 0xFE && raw[1] == 0xFF { // UTF-16 BE with BOM - let u16_vec: Vec = raw[2..].chunks_exact(2) + let u16_vec: Vec = raw[2..] + .chunks_exact(2) .map(|c| u16::from_be_bytes([c[0], c[1]])) .collect(); return Ok(String::from_utf16_lossy(&u16_vec)); } } - + // Try UTF-8, then fallback to lossy if let Ok(text) = String::from_utf8(raw.clone()) { return Ok(text); } - + // Try UTF-16 without BOM if raw.len() % 2 == 0 { - let u16_vec: Vec = raw.chunks_exact(2) + let u16_vec: Vec = raw + .chunks_exact(2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect(); let text = String::from_utf16_lossy(&u16_vec); @@ -130,95 +136,112 @@ impl OptimizationParser { return Ok(text); } } - + Ok(String::from_utf8_lossy(&raw).to_string()) } fn parse_html(&self, text: &str) -> Result> { let mut results = Vec::new(); let mut headers: Vec = Vec::new(); - + // Find all table rows let row_regex = regex::Regex::new(r"]*>(.*?)")?; let cell_regex = regex::Regex::new(r"]*>(.*?)")?; let tag_regex = regex::Regex::new(r"<[^>]+>")?; - + for row_caps in row_regex.captures_iter(text) { let row = &row_caps[1]; - let cells: Vec = cell_regex.captures_iter(row) + let cells: Vec = cell_regex + .captures_iter(row) .map(|c| { let cell = &c[1]; - tag_regex.replace_all(cell, "").trim().to_string().replace(',', "") + tag_regex + .replace_all(cell, "") + .trim() + .to_string() + .replace(',', "") }) .collect(); - + if cells.is_empty() { continue; } - + // Header row detection if headers.is_empty() && cells[0].to_lowercase().contains("pass") { headers = cells; continue; } - + // Data row if !headers.is_empty() && cells[0].parse::().is_ok() { - let row_map: HashMap = headers.iter() + let row_map: HashMap = headers + .iter() .zip(cells.iter()) .map(|(h, c)| (h.to_lowercase().replace(' ', "_"), c.clone())) .collect(); - + if let Some(pass) = self.row_to_pass(&row_map) { results.push(pass); } } } - + Ok(results) } fn parse_xml(&self, text: &str) -> Result> { let mut results = Vec::new(); - + // Parse SpreadsheetML XML let doc = roxmltree::Document::parse(text)?; - + // Find all rows in Worksheet/Table for node in doc.descendants() { - if node.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Row")) || - node.has_tag_name("Row") { - let cells: Vec = node.descendants() + if node.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Row")) + || node.has_tag_name("Row") + { + let cells: Vec = node + .descendants() .filter(|n: &roxmltree::Node<'_, '_>| { - n.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Data")) || - n.has_tag_name("Data") + n.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Data")) + || n.has_tag_name("Data") }) .filter_map(|n| n.text()) .map(|t| t.trim().to_string().replace(',', "")) .collect(); - + if cells.is_empty() { continue; } - + // Check if first cell is a pass number if let Ok(pass_num) = cells[0].parse::() { if pass_num > 0 { let mut row_map = HashMap::new(); - + // Standard MT5 optimization report columns let headers = vec![ - "pass", "result", "profit", "total_trades", "profit_factor", - "expected_payoff", "drawdown_pct", "recovery_factor", "sharpe_ratio", - "custom", "consecutive_wins", "consecutive_losses", + "pass", + "result", + "profit", + "total_trades", + "profit_factor", + "expected_payoff", + "drawdown_pct", + "recovery_factor", + "sharpe_ratio", + "custom", + "consecutive_wins", + "consecutive_losses", ]; - + for (i, cell) in cells.iter().enumerate() { if let Some(header) = headers.get(i) { row_map.insert(header.to_string(), cell.clone()); } } - + if let Some(pass) = self.row_to_pass(&row_map) { results.push(pass); } @@ -226,39 +249,59 @@ impl OptimizationParser { } } } - + Ok(results) } fn row_to_pass(&self, row: &HashMap) -> Option { - let pass = row.get("pass").or_else(|| row.get("#")) + let pass = row + .get("pass") + .or_else(|| row.get("#")) .and_then(|v| v.parse().ok())?; - - let profit = row.get("profit").or_else(|| row.get("total_net_profit")) + + let profit = row + .get("profit") + .or_else(|| row.get("total_net_profit")) .and_then(|v| v.replace(' ', "").parse().ok())?; - - let total_trades = row.get("total_trades").or_else(|| row.get("trades")) + + let total_trades = row + .get("total_trades") + .or_else(|| row.get("trades")) .and_then(|v| v.parse().ok())?; - - let profit_factor = row.get("profit_factor") - .and_then(|v| v.parse().ok())?; - - let expected_payoff = row.get("expected_payoff") - .and_then(|v| v.parse().ok())?; - - let drawdown_pct = row.get("drawdown_pct").or_else(|| row.get("max_drawdown")) + + let profit_factor = row.get("profit_factor").and_then(|v| v.parse().ok())?; + + let expected_payoff = row.get("expected_payoff").and_then(|v| v.parse().ok())?; + + let drawdown_pct = row + .get("drawdown_pct") + .or_else(|| row.get("max_drawdown")) .and_then(|v| v.trim_end_matches('%').trim().parse().ok())?; - + // Extract parameter values from row - let params: HashMap = row.iter() - .filter(|(k, _)| ![ - "pass", "result", "profit", "total_trades", "profit_factor", - "expected_payoff", "drawdown_pct", "max_drawdown", "recovery_factor", - "sharpe_ratio", "custom", "consecutive_wins", "consecutive_losses" - ].contains(&k.as_str())) + let params: HashMap = row + .iter() + .filter(|(k, _)| { + ![ + "pass", + "result", + "profit", + "total_trades", + "profit_factor", + "expected_payoff", + "drawdown_pct", + "max_drawdown", + "recovery_factor", + "sharpe_ratio", + "custom", + "consecutive_wins", + "consecutive_losses", + ] + .contains(&k.as_str()) + }) .map(|(k, v)| (k.clone(), v.clone())) .collect(); - + Some(OptimizationPass { pass, profit, @@ -270,17 +313,37 @@ impl OptimizationParser { }) } - pub fn find_best_pass<'a>(&self, passes: &'a [OptimizationPass], criteria: &str) -> Option<&'a OptimizationPass> { + pub fn find_best_pass<'a>( + &self, + passes: &'a [OptimizationPass], + criteria: &str, + ) -> Option<&'a OptimizationPass> { match criteria { - "profit" => passes.iter().max_by(|a, b| a.profit.partial_cmp(&b.profit).unwrap()), - "profit_factor" => passes.iter().max_by(|a, b| a.profit_factor.partial_cmp(&b.profit_factor).unwrap()), + "profit" => passes + .iter() + .max_by(|a, b| a.profit.partial_cmp(&b.profit).unwrap()), + "profit_factor" => passes + .iter() + .max_by(|a, b| a.profit_factor.partial_cmp(&b.profit_factor).unwrap()), "sharpe" => passes.iter().max_by(|a, b| { - let a_sharpe = a.params.get("sharpe_ratio").and_then(|v| v.parse::().ok()).unwrap_or(0.0); - let b_sharpe = b.params.get("sharpe_ratio").and_then(|v| v.parse::().ok()).unwrap_or(0.0); + let a_sharpe = a + .params + .get("sharpe_ratio") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0); + let b_sharpe = b + .params + .get("sharpe_ratio") + .and_then(|v| v.parse::().ok()) + .unwrap_or(0.0); a_sharpe.partial_cmp(&b_sharpe).unwrap() }), - "drawdown" => passes.iter().min_by(|a, b| a.drawdown_pct.partial_cmp(&b.drawdown_pct).unwrap()), - _ => passes.iter().max_by(|a, b| a.profit.partial_cmp(&b.profit).unwrap()), + "drawdown" => passes + .iter() + .min_by(|a, b| a.drawdown_pct.partial_cmp(&b.drawdown_pct).unwrap()), + _ => passes + .iter() + .max_by(|a, b| a.profit.partial_cmp(&b.profit).unwrap()), } } } diff --git a/src/pipeline/backtest.rs b/src/pipeline/backtest.rs index 52a25d5..973e6f8 100644 --- a/src/pipeline/backtest.rs +++ b/src/pipeline/backtest.rs @@ -10,11 +10,52 @@ use tokio::time::{sleep, Duration}; use crate::analytics::{DealAnalyzer, ReportExtractor}; use crate::compile::MqlCompiler; use crate::models::config::Config; -use crate::models::report::{PipelineMetadata, FilePaths, BacktestJob}; +use crate::models::report::{BacktestJob, FilePaths, PipelineMetadata}; use crate::storage::{ReportDb, ReportEntry}; type NotificationCallback = Arc; +/// Read a .set file that may be UTF-16LE (with BOM) or UTF-8, returning UTF-8 text. +/// Mirrors optimization::optimizer's copy of the same logic (kept local rather +/// than shared, matching the existing pattern in this codebase). +fn read_set_file_as_utf8(path: &Path) -> Result { + let bytes = fs::read(path)?; + if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { + let utf16_data: Vec = bytes[2..] + .chunks_exact(2) + .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]])) + .collect(); + String::from_utf16(&utf16_data).map_err(|e| anyhow!("Failed to decode UTF-16LE: {}", e)) + } else { + String::from_utf8(bytes).map_err(|e| anyhow!("Failed to decode as UTF-8: {}", e)) + } +} + +/// Copy an arbitrary-encoding .set file into `/.set` +/// as UTF-16LE with BOM (the format the MT5 tester expects), and return just the +/// filename to use as `ExpertParameters=` - a relative name inside the Wine +/// environment, avoiding the need to translate a host (e.g. POSIX) path into a +/// Windows/Wine path that MT5 could resolve on its own. +fn stage_set_file_for_tester( + src: &str, + tester_profiles_dir: &Path, + expert: &str, +) -> Result { + let content = read_set_file_as_utf8(Path::new(src))?; + fs::create_dir_all(tester_profiles_dir)?; + let dst = tester_profiles_dir.join(format!("{}.set", expert)); + + let mut utf16_content: Vec = vec![0xFEFF]; // BOM + utf16_content.extend(content.encode_utf16()); + let bytes: Vec = utf16_content + .iter() + .flat_map(|&c| [(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8]) + .collect(); + fs::write(&dst, bytes)?; + + Ok(format!("{}.set", expert)) +} + pub struct BacktestPipeline { config: Config, compiler: MqlCompiler, @@ -61,7 +102,7 @@ impl BacktestPipeline { let compiler = MqlCompiler::new(config.clone()); let extractor = ReportExtractor::new(); let analyzer = DealAnalyzer::new(); - + Self { config, compiler, @@ -75,7 +116,7 @@ impl BacktestPipeline { let compiler = MqlCompiler::new(config.clone()); let extractor = ReportExtractor::new(); let analyzer = DealAnalyzer::new(); - + Self { config, compiler, @@ -130,7 +171,9 @@ impl BacktestPipeline { if !params.skip_analyze { self.log_progress(&progress_log, "ANALYZE").await; - let analysis = self.analyzer.analyze(&extraction.deals, &extraction.metrics); + let analysis = self + .analyzer + .analyze(&extraction.deals, &extraction.metrics); let analysis_path = report_dir.join("analysis.json"); fs::write(&analysis_path, serde_json::to_string_pretty(&analysis)?)?; @@ -139,19 +182,21 @@ impl BacktestPipeline { self.log_progress(&progress_log, "DONE").await; let duration = (chrono::Utc::now() - start_time).num_seconds(); - self.save_metadata(¶ms, &report_dir, duration, extraction.deals.is_empty()).await?; + self.save_metadata(¶ms, &report_dir, duration, extraction.deals.is_empty()) + .await?; // Register in the SQLite report registry and store deals. - let db = self.register_in_db( - &report_id, - ¶ms, - &report_dir, - charts_dir.as_deref(), - set_snapshot.as_deref(), - &extraction.metrics, - duration, - ) - .await; + let db = self + .register_in_db( + &report_id, + ¶ms, + &report_dir, + charts_dir.as_deref(), + set_snapshot.as_deref(), + &extraction.metrics, + duration, + ) + .await; if let Some(db) = db { if let Err(e) = db.insert_deals(&report_id, &extraction.deals) { @@ -160,7 +205,8 @@ impl BacktestPipeline { } let message = if extraction.deals.is_empty() { - "Backtest completed successfully, but EA did not execute any trades during this period".to_string() + "Backtest completed successfully, but EA did not execute any trades during this period" + .to_string() } else { "Backtest completed successfully".to_string() }; @@ -198,9 +244,14 @@ impl BacktestPipeline { self.log_progress(&progress_log, "BACKTEST").await; // Get MT5 paths - let mt5_dir = self.config.mt5_dir() + let mt5_dir = self + .config + .mt5_dir() .ok_or_else(|| anyhow!("MT5 directory not configured"))?; - let wine_exe = self.config.wine_executable.as_ref() + let wine_exe = self + .config + .wine_executable + .as_ref() .ok_or_else(|| anyhow!("wine_executable not configured"))?; let wine_prefix = mt5_dir .parent() @@ -223,7 +274,8 @@ impl BacktestPipeline { // Launch MT5 (fire and forget) let mut cmd = self.build_wine_launch(wine_exe, &wine_prefix)?; - let child = cmd.stdin(std::process::Stdio::null()) + let child = cmd + .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn()?; @@ -293,7 +345,8 @@ impl BacktestPipeline { notification_callback, config_clone, params_clone, - ).await; + ) + .await; }); Ok(job) @@ -356,11 +409,19 @@ impl BacktestPipeline { if let Err(e) = db.insert_deals(report_id, &extraction.deals) { tracing::warn!("launch_backtest: failed to store deals in DB: {}", e); } - tracing::info!("launch_backtest: extracted {} deals for {}", extraction.deals.len(), report_id); + tracing::info!( + "launch_backtest: extracted {} deals for {}", + extraction.deals.len(), + report_id + ); true } Err(e) => { - tracing::warn!("launch_backtest: extraction failed for {}: {}", report_id, e); + tracing::warn!( + "launch_backtest: extraction failed for {}: {}", + report_id, + e + ); false } } @@ -383,9 +444,7 @@ impl BacktestPipeline { // Inactivity watchdog: kill MT5 if tester agent log hasn't grown for this long. // Catches EAs that stall (no ticks processed) or flat periods with zero trades. - let inactivity_threshold = Duration::from_secs( - params.inactivity_kill_secs.unwrap_or(0) - ); + let inactivity_threshold = Duration::from_secs(params.inactivity_kill_secs.unwrap_or(0)); let mut last_log_size: u64 = 0; let mut last_log_activity = tokio::time::Instant::now(); let inactivity_enabled = inactivity_threshold.as_secs() > 0; @@ -399,35 +458,62 @@ impl BacktestPipeline { expected_report.clone() } else { // Build alternate extension path without touching expected_report extension - let stem = expected_report.file_stem() + let stem = expected_report + .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default(); expected_report.with_file_name(format!("{}.{}", stem, ext)) }; if candidate.exists() { - tracing::info!("Backtest {} completed: report found at {}", report_id, candidate.display()); - let extracted = Self::extract_and_store(&candidate, &report_dir, &report_id, &config, ¶ms).await; + tracing::info!( + "Backtest {} completed: report found at {}", + report_id, + candidate.display() + ); + let extracted = Self::extract_and_store( + &candidate, + &report_dir, + &report_id, + &config, + ¶ms, + ) + .await; if extracted { let _ = fs::remove_file(&candidate); } else { - tracing::warn!("Backtest {}: extraction failed, keeping report file at {}", report_id, candidate.display()); + tracing::warn!( + "Backtest {}: extraction failed, keeping report file at {}", + report_id, + candidate.display() + ); } // When ShutdownTerminal=0 (shutdown=false), MT5 stays running after the // test so the report can be written reliably. Kill it ourselves now that // extraction is done so we don't leave zombie Wine processes. if !params.shutdown && Self::is_mt5_running() { - tracing::info!("Backtest {}: killing MT5 after report extraction (shutdown=false)", report_id); + tracing::info!( + "Backtest {}: killing MT5 after report extraction (shutdown=false)", + report_id + ); let _ = std::process::Command::new("pkill") .args(["-TERM", "-f", "terminal64\\.exe"]) .output(); } - Self::update_job_status(&report_dir, "completed", Some(candidate.to_string_lossy().to_string())).await; + Self::update_job_status( + &report_dir, + "completed", + Some(candidate.to_string_lossy().to_string()), + ) + .await; if let Some(ref callback) = notification_callback { - callback("backtest_completed", json!({ - "report_id": report_id, - "report_path": candidate.to_string_lossy().to_string(), - "status": "completed" - })); + callback( + "backtest_completed", + json!({ + "report_id": report_id, + "report_path": candidate.to_string_lossy().to_string(), + "status": "completed" + }), + ); } return; } @@ -446,13 +532,13 @@ impl BacktestPipeline { // the report file; if it isn't there by then, it won't appear. if inactivity_enabled && Self::is_mt5_running() { if let Some(log_path) = Self::find_active_tester_agent_log(&config) { - let current_size = fs::metadata(&log_path) - .map(|m| m.len()) - .unwrap_or(0); + let current_size = fs::metadata(&log_path).map(|m| m.len()).unwrap_or(0); if current_size > last_log_size { last_log_size = current_size; last_log_activity = tokio::time::Instant::now(); - } else if last_log_activity.elapsed() >= inactivity_threshold && last_log_size > 0 { + } else if last_log_activity.elapsed() >= inactivity_threshold + && last_log_size > 0 + { tracing::info!( "Backtest {}: tester log inactive for {}s — waiting 30s for HTML report, then killing MT5", report_id, inactivity_threshold.as_secs() @@ -466,7 +552,8 @@ impl BacktestPipeline { let candidate = if *ext == "htm" { expected_report.clone() } else { - let stem = expected_report.file_stem() + let stem = expected_report + .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_default(); expected_report.with_file_name(format!("{}.{}", stem, ext)) @@ -476,7 +563,9 @@ impl BacktestPipeline { break; } } - if html_found.is_some() { break; } + if html_found.is_some() { + break; + } // Also scan for any newly created report. if let Some(parent) = reports_parent { if let Some(path) = Self::find_newest_report(parent, poll_start) { @@ -488,7 +577,10 @@ impl BacktestPipeline { } // Kill MT5 now — it either wrote the report or it won't. - tracing::info!("Backtest {}: killing MT5 after inactivity+HTML-wait window", report_id); + tracing::info!( + "Backtest {}: killing MT5 after inactivity+HTML-wait window", + report_id + ); let _ = std::process::Command::new("pkill") .args(["-TERM", "-f", "terminal64\\.exe"]) .output(); @@ -498,15 +590,36 @@ impl BacktestPipeline { .output(); if let Some(path) = html_found { - tracing::info!("Backtest {}: HTML report found during wait: {}", report_id, path.display()); - let extracted = Self::extract_and_store(&path, &report_dir, &report_id, &config, ¶ms).await; - if extracted { let _ = fs::remove_file(&path); } - Self::update_job_status(&report_dir, "completed", Some(path.to_string_lossy().to_string())).await; + tracing::info!( + "Backtest {}: HTML report found during wait: {}", + report_id, + path.display() + ); + let extracted = Self::extract_and_store( + &path, + &report_dir, + &report_id, + &config, + ¶ms, + ) + .await; + if extracted { + let _ = fs::remove_file(&path); + } + Self::update_job_status( + &report_dir, + "completed", + Some(path.to_string_lossy().to_string()), + ) + .await; if let Some(ref callback) = notification_callback { - callback("backtest_completed", json!({ - "report_id": report_id, - "status": "completed" - })); + callback( + "backtest_completed", + json!({ + "report_id": report_id, + "status": "completed" + }), + ); } return; } @@ -514,14 +627,26 @@ impl BacktestPipeline { // No HTML — fall back to journal extraction. sleep(Duration::from_secs(1)).await; if let Some(log) = Self::find_active_tester_agent_log(&config) { - if Self::extract_from_journal(&log, &report_dir, &report_id, &config, ¶ms).await { - Self::update_job_status(&report_dir, "completed_no_html", None).await; + if Self::extract_from_journal( + &log, + &report_dir, + &report_id, + &config, + ¶ms, + ) + .await + { + Self::update_job_status(&report_dir, "completed_no_html", None) + .await; if let Some(ref callback) = notification_callback { - callback("backtest_completed", json!({ - "report_id": report_id, - "status": "completed_no_html", - "reason": "extracted from journal after inactivity kill (no HTML produced)" - })); + callback( + "backtest_completed", + json!({ + "report_id": report_id, + "status": "completed_no_html", + "reason": "extracted from journal after inactivity kill (no HTML produced)" + }), + ); } return; } @@ -543,7 +668,10 @@ impl BacktestPipeline { let reports_parent = match expected_report.parent() { Some(p) => p, None => { - tracing::error!("Backtest {}: expected_report path has no parent", report_id); + tracing::error!( + "Backtest {}: expected_report path has no parent", + report_id + ); Self::update_job_status(&report_dir, "failed", None).await; return; } @@ -554,76 +682,123 @@ impl BacktestPipeline { if let Some(path) = Self::find_newest_report(reports_parent, poll_start) { tracing::info!( "Backtest {}: found report after {}s — {}", - report_id, attempt, path.display() + report_id, + attempt, + path.display() ); found_report = Some(path); break; } - tracing::debug!("Backtest {}: no report yet ({}s elapsed after MT5 exit)", report_id, attempt); + tracing::debug!( + "Backtest {}: no report yet ({}s elapsed after MT5 exit)", + report_id, + attempt + ); } if let Some(path) = found_report { - tracing::info!("Backtest {} completed: found report {}", report_id, path.display()); - let extracted = Self::extract_and_store(&path, &report_dir, &report_id, &config, ¶ms).await; + tracing::info!( + "Backtest {} completed: found report {}", + report_id, + path.display() + ); + let extracted = + Self::extract_and_store(&path, &report_dir, &report_id, &config, ¶ms) + .await; if extracted { let _ = fs::remove_file(&path); } else { - tracing::warn!("Backtest {}: extraction failed, keeping report at {}", report_id, path.display()); + tracing::warn!( + "Backtest {}: extraction failed, keeping report at {}", + report_id, + path.display() + ); } - Self::update_job_status(&report_dir, "completed", Some(path.to_string_lossy().to_string())).await; + Self::update_job_status( + &report_dir, + "completed", + Some(path.to_string_lossy().to_string()), + ) + .await; if let Some(ref callback) = notification_callback { - callback("backtest_completed", json!({ - "report_id": report_id, - "report_path": path.to_string_lossy().to_string(), - "status": "completed" - })); + callback( + "backtest_completed", + json!({ + "report_id": report_id, + "report_path": path.to_string_lossy().to_string(), + "status": "completed" + }), + ); } return; } // No HTML report found — fallback to journal extraction. - tracing::warn!("Backtest {}: no HTML report found, trying journal extraction", report_id); + tracing::warn!( + "Backtest {}: no HTML report found, trying journal extraction", + report_id + ); if let Some(log) = Self::find_active_tester_agent_log(&config) { - if Self::extract_from_journal(&log, &report_dir, &report_id, &config, ¶ms).await { + if Self::extract_from_journal(&log, &report_dir, &report_id, &config, ¶ms) + .await + { Self::update_job_status(&report_dir, "completed_no_html", None).await; if let Some(ref callback) = notification_callback { - callback("backtest_completed", json!({ - "report_id": report_id, - "status": "completed_no_html", - "reason": "extracted from tester journal (HTML report not produced)" - })); + callback( + "backtest_completed", + json!({ + "report_id": report_id, + "status": "completed_no_html", + "reason": "extracted from tester journal (HTML report not produced)" + }), + ); } return; } } - tracing::warn!("Backtest {} failed: MT5 exited without producing a report", report_id); + tracing::warn!( + "Backtest {} failed: MT5 exited without producing a report", + report_id + ); Self::update_job_status(&report_dir, "failed", None).await; if let Some(ref callback) = notification_callback { - callback("backtest_failed", json!({ - "report_id": report_id, - "status": "failed", - "reason": "MT5 exited without producing a report or recoverable journal" - })); + callback( + "backtest_failed", + json!({ + "report_id": report_id, + "status": "failed", + "reason": "MT5 exited without producing a report or recoverable journal" + }), + ); } return; } if tokio::time::Instant::now() > deadline { - tracing::warn!("Backtest {} timed out after {} seconds", report_id, timeout_secs); + tracing::warn!( + "Backtest {} timed out after {} seconds", + report_id, + timeout_secs + ); // Last-chance journal extraction on timeout if let Some(log) = Self::find_active_tester_agent_log(&config) { - if Self::extract_from_journal(&log, &report_dir, &report_id, &config, ¶ms).await { + if Self::extract_from_journal(&log, &report_dir, &report_id, &config, ¶ms) + .await + { Self::update_job_status(&report_dir, "completed_no_html", None).await; return; } } Self::update_job_status(&report_dir, "timeout", None).await; if let Some(ref callback) = notification_callback { - callback("backtest_timeout", json!({ - "report_id": report_id, - "status": "timeout", - "timeout_seconds": timeout_secs - })); + callback( + "backtest_timeout", + json!({ + "report_id": report_id, + "status": "timeout", + "timeout_seconds": timeout_secs + }), + ); } return; } @@ -670,7 +845,11 @@ impl BacktestPipeline { let size = meta.len(); // Prefer local agents (127.0.0.1) — they log actual deal execution - let priority: u8 = if agent_str.contains("127.0.0.1") { 1 } else { 0 }; + let priority: u8 = if agent_str.contains("127.0.0.1") { + 1 + } else { + 0 + }; let is_better = match &best { None => true, @@ -690,7 +869,8 @@ impl BacktestPipeline { let bytes = fs::read(log_path).ok()?; let text = if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE { // UTF-16 LE with BOM - let words: Vec = bytes[2..].chunks_exact(2) + let words: Vec = bytes[2..] + .chunks_exact(2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect(); String::from_utf16_lossy(&words).to_string() @@ -707,6 +887,7 @@ impl BacktestPipeline { /// - No open position → "in" /// - Same direction as existing position → "in" (grid/martingale add) /// - Opposite direction → "out" (closing) + /// /// Profit/balance are unavailable in the journal; they remain 0.0. pub fn parse_journal_deals(lines: &[String]) -> (Vec, f64, String) { use regex::Regex; @@ -730,11 +911,11 @@ impl BacktestPipeline { for line in lines { if let Some(cap) = deal_re.captures(line) { - let sim_time = cap[1].to_string(); - let deal_num = cap[2].to_string(); + let sim_time = cap[1].to_string(); + let deal_num = cap[2].to_string(); let direction = cap[3].to_string(); // "buy" | "sell" let volume: f64 = cap[4].parse().unwrap_or(0.0); - let symbol = cap[5].to_string(); + let symbol = cap[5].to_string(); let price: f64 = cap[6].parse().unwrap_or(0.0); // Skip duplicate deal entries (MT5 writes each deal twice) @@ -750,7 +931,7 @@ impl BacktestPipeline { // flat → opening a new position "in" } else if (current > 0.0 && direction == "buy") - || (current < 0.0 && direction == "sell") + || (current < 0.0 && direction == "sell") { // same direction as existing → adding (grid/martingale) "in" @@ -768,20 +949,20 @@ impl BacktestPipeline { } deals.push(crate::models::deals::Deal { - time: sim_time, - deal: deal_num, + time: sim_time, + deal: deal_num, symbol, - deal_type: direction, - entry: entry_type.to_string(), + deal_type: direction, + entry: entry_type.to_string(), volume, price, - order: String::new(), + order: String::new(), commission: 0.0, - swap: 0.0, - profit: 0.0, // not available in journal - balance: 0.0, // not available in journal - comment: String::new(), - magic: None, + swap: 0.0, + profit: 0.0, // not available in journal + balance: 0.0, // not available in journal + comment: String::new(), + magic: None, }); } if let Some(cap) = balance_re.captures(line) { @@ -807,20 +988,28 @@ impl BacktestPipeline { let lines = match Self::read_tester_agent_log(log_path) { Some(l) => l, None => { - tracing::warn!("Journal extraction: could not read log {}", log_path.display()); + tracing::warn!( + "Journal extraction: could not read log {}", + log_path.display() + ); return false; } }; let (deals, final_balance_pips, progress) = Self::parse_journal_deals(&lines); if deals.is_empty() { - tracing::warn!("Journal extraction: no deals found in {}", log_path.display()); + tracing::warn!( + "Journal extraction: no deals found in {}", + log_path.display() + ); return false; } tracing::info!( "Journal extraction: {} deals, final balance {} pips, {}", - deals.len(), final_balance_pips, progress + deals.len(), + final_balance_pips, + progress ); // Save journal summary to report_dir @@ -833,7 +1022,10 @@ impl BacktestPipeline { "progress": progress, "note": "No HTML report was produced. Deals extracted from tester agent log. profit/balance fields are 0 (not available in log format)." }); - let _ = fs::write(&summary_path, serde_json::to_string_pretty(&summary).unwrap_or_default()); + let _ = fs::write( + &summary_path, + serde_json::to_string_pretty(&summary).unwrap_or_default(), + ); // Register in DB with partial metrics let db = crate::storage::ReportDb::new(&Config::db_path()); @@ -865,7 +1057,11 @@ impl BacktestPipeline { leverage: Some(params.leverage as i64), duration_seconds: None, tags: vec!["journal-only".to_string()], - notes: Some(format!("Extracted from journal: {} deals, final balance {} pips. No HTML report.", deals.len(), final_balance_pips)), + notes: Some(format!( + "Extracted from journal: {} deals, final balance {} pips. No HTML report.", + deals.len(), + final_balance_pips + )), verdict: None, }; if db.insert(&entry).is_err() { @@ -916,10 +1112,8 @@ impl BacktestPipeline { .unwrap_or(false); if is_chart { - if !found { - if fs::create_dir_all(&charts_dir).is_err() { - return None; - } + if !found && fs::create_dir_all(&charts_dir).is_err() { + return None; } let dest = charts_dir.join(entry.file_name()); let _ = fs::rename(&path, &dest); @@ -927,7 +1121,11 @@ impl BacktestPipeline { } } - if found { Some(charts_dir) } else { None } + if found { + Some(charts_dir) + } else { + None + } } /// Copy the set file into the report dir as set_snapshot.set. @@ -946,6 +1144,9 @@ impl BacktestPipeline { Some(dest) } + // Internal helper: each argument maps 1:1 to a ReportEntry field written below; + // grouping them into a struct would only relocate the parameter list. + #[allow(clippy::too_many_arguments)] async fn register_in_db( &self, report_id: &str, @@ -1001,8 +1202,12 @@ impl BacktestPipeline { async fn compile_ea(&self, expert: &str, timeout_secs: u64) -> Result<()> { let mut search_paths = vec![ - PathBuf::from(&self.config.get("project_dir")).join("src/experts").join(format!("{}.mq5", expert)), - PathBuf::from(&self.config.get("project_dir")).join("src").join(format!("{}.mq5", expert)), + PathBuf::from(&self.config.get("project_dir")) + .join("src/experts") + .join(format!("{}.mq5", expert)), + PathBuf::from(&self.config.get("project_dir")) + .join("src") + .join(format!("{}.mq5", expert)), PathBuf::from(&self.config.get("project_dir")).join(format!("{}.mq5", expert)), PathBuf::from("src/experts").join(format!("{}.mq5", expert)), PathBuf::from("src").join(format!("{}.mq5", expert)), @@ -1010,23 +1215,32 @@ impl BacktestPipeline { ]; // Also search in MT5 Experts dir: Experts/{expert}/{expert}.mq5 and Experts/{expert}.mq5 if let Some(experts_dir) = &self.config.experts_dir { - search_paths.push(PathBuf::from(experts_dir).join(expert).join(format!("{}.mq5", expert))); + search_paths.push( + PathBuf::from(experts_dir) + .join(expert) + .join(format!("{}.mq5", expert)), + ); search_paths.push(PathBuf::from(experts_dir).join(format!("{}.mq5", expert))); } let source_path = search_paths .into_iter() .find(|p| p.exists()) - .ok_or_else(|| anyhow!("Cannot find {}.mq5 — searched project_dir and MT5 Experts dir", expert))?; + .ok_or_else(|| { + anyhow!( + "Cannot find {}.mq5 — searched project_dir and MT5 Experts dir", + expert + ) + })?; let timeout = std::time::Duration::from_secs(timeout_secs.min(300)); // Max 5 min for compile - let result = self.compiler.compile_with_timeout(&source_path.to_string_lossy(), timeout).await?; - + let result = self + .compiler + .compile_with_timeout(&source_path.to_string_lossy(), timeout) + .await?; + if !result.success { - return Err(anyhow!( - "Compilation failed: {}", - result.errors.join("; ") - )); + return Err(anyhow!("Compilation failed: {}", result.errors.join("; "))); } Ok(()) @@ -1036,12 +1250,10 @@ impl BacktestPipeline { if let Some(cache_dir) = &self.config.tester_cache_dir { let cache_path = Path::new(cache_dir); if cache_path.exists() { - for entry in walkdir::WalkDir::new(cache_path) { - if let Ok(entry) = entry { - let path = entry.path(); - if path.extension().map(|e| e == "tst").unwrap_or(false) { - let _ = fs::remove_file(path); - } + for entry in walkdir::WalkDir::new(cache_path).into_iter().flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "tst").unwrap_or(false) { + let _ = fs::remove_file(path); } } } @@ -1060,27 +1272,31 @@ impl BacktestPipeline { } async fn reset_terminal_ini(&self) -> Result<()> { - let mt5_dir = self.config.mt5_dir() + let mt5_dir = self + .config + .mt5_dir() .ok_or_else(|| anyhow!("MT5 directory not configured"))?; - + let terminal_ini = mt5_dir.join("config").join("terminal.ini"); if !terminal_ini.exists() { return Ok(()); } let content = fs::read(&terminal_ini)?; - - let (text, encoding) = if content.starts_with(&[0xFF, 0xFE]) || content.starts_with(&[0xFE, 0xFF]) { - let text = String::from_utf16_lossy( - content.chunks_exact(2) - .map(|c| u16::from_le_bytes([c[0], c[1]])) - .collect::>() - .as_slice() - ); - (text, "utf-16") - } else { - (String::from_utf8_lossy(&content).to_string(), "utf-8") - }; + + let (text, encoding) = + if content.starts_with(&[0xFF, 0xFE]) || content.starts_with(&[0xFE, 0xFF]) { + let text = String::from_utf16_lossy( + content + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect::>() + .as_slice(), + ); + (text, "utf-16") + } else { + (String::from_utf8_lossy(&content).to_string(), "utf-8") + }; let updated = text .replace("OptMode=-1", "OptMode=0") @@ -1088,30 +1304,33 @@ impl BacktestPipeline { let output = if encoding == "utf-16" { let utf16: Vec = updated.encode_utf16().collect(); - let bytes: Vec = utf16.iter() - .flat_map(|&c| c.to_le_bytes()) - .collect(); + let bytes: Vec = utf16.iter().flat_map(|&c| c.to_le_bytes()).collect(); bytes } else { updated.into_bytes() }; fs::write(&terminal_ini, output)?; - + Ok(()) } async fn run_backtest(&self, params: &BacktestParams, report_id: &str) -> Result { - let mt5_dir = self.config.mt5_dir() + let mt5_dir = self + .config + .mt5_dir() .ok_or_else(|| anyhow!("MT5 directory not configured"))?; - let wine_exe = self.config.wine_executable.as_ref() + let wine_exe = self + .config + .wine_executable + .as_ref() .ok_or_else(|| anyhow!("wine_executable not configured"))?; // mt5_dir = {prefix}/drive_c/Program Files/MetaTrader 5 // WINEPREFIX = three levels up let wine_prefix = mt5_dir - .parent() // .../drive_c/Program Files + .parent() // .../drive_c/Program Files .and_then(|p| p.parent()) // .../drive_c .and_then(|p| p.parent()) // .../ .map(|p| p.to_path_buf()) @@ -1145,7 +1364,11 @@ impl BacktestPipeline { // Give MT5 time to fully initialize before polling. // MT5 app startup (Wine init + network auth + tester) typically takes 10–15 s. // Configurable via startup_delay_secs parameter (default 10s for faster launches). - let delay = if params.startup_delay_secs > 0 { params.startup_delay_secs } else { 10 }; + let delay = if params.startup_delay_secs > 0 { + params.startup_delay_secs + } else { + 10 + }; sleep(Duration::from_secs(delay)).await; // Poll for the report file (MT5 writes it when the backtest completes). @@ -1162,7 +1385,11 @@ impl BacktestPipeline { let candidate = reports_dir.join(format!("{}{}", report_id, ext)); tracing::debug!("poll t+{}s: checking {}", elapsed, candidate.display()); if candidate.exists() { - tracing::info!("poll t+{}s: found exact report {}", elapsed, candidate.display()); + tracing::info!( + "poll t+{}s: found exact report {}", + elapsed, + candidate.display() + ); return Ok(candidate); } } @@ -1171,7 +1398,12 @@ impl BacktestPipeline { // a false "not running" when the new instance is still starting up. let in_grace = launch_instant.elapsed() <= grace_period; let mt5_alive = Self::is_mt5_running(); - tracing::info!("poll t+{}s: in_grace={} mt5_alive={}", elapsed, in_grace, mt5_alive); + tracing::info!( + "poll t+{}s: in_grace={} mt5_alive={}", + elapsed, + in_grace, + mt5_alive + ); if !in_grace && !mt5_alive { // MT5 writes the .htm report file right before exiting. There is a @@ -1190,7 +1422,10 @@ impl BacktestPipeline { } if tokio::time::Instant::now() > deadline { - return Err(anyhow!("Timeout: no report after {} seconds", params.timeout)); + return Err(anyhow!( + "Timeout: no report after {} seconds", + params.timeout + )); } sleep(Duration::from_secs(2)).await; } @@ -1201,7 +1436,9 @@ impl BacktestPipeline { /// rather than requiring fresh credentials. This is more reliable than /config: alone, /// which requires a password for fresh authentication. fn update_terminal_ini(&self, params: &BacktestParams, report_id: &str) -> Result<()> { - let mt5_dir = self.config.mt5_dir() + let mt5_dir = self + .config + .mt5_dir() .ok_or_else(|| anyhow!("MT5 directory not configured"))?; // Portable mode uses config/ inside the install dir; non-portable uses the root. let terminal_ini = if mt5_dir.join("config").exists() { @@ -1212,7 +1449,8 @@ impl BacktestPipeline { let raw = fs::read(&terminal_ini).unwrap_or_default(); let text = if raw.starts_with(&[0xFF, 0xFE]) { - raw[2..].chunks_exact(2) + raw[2..] + .chunks_exact(2) .map(|c| u16::from_le_bytes([c[0], c[1]])) .collect::>() .iter() @@ -1223,16 +1461,23 @@ impl BacktestPipeline { }; let period = match params.timeframe.as_str() { - "M1" => 1u32, "M5" => 5, "M15" => 15, "M30" => 30, - "H1" => 60, "H4" => 240, "D1" => 1440, - _ => 5, + "M1" => 1u32, + "M5" => 5, + "M15" => 15, + "M30" => 30, + "H1" => 60, + "H4" => 240, + "D1" => 1440, + _ => 5, }; let from_ts = Self::date_str_to_unix(¶ms.from_date)?; - let to_ts = Self::date_str_to_unix(¶ms.to_date)?; + let to_ts = Self::date_str_to_unix(¶ms.to_date)?; let currency = self.config.backtest_currency.as_deref().unwrap_or("USD"); let expert_path = if let Some(experts_dir) = &self.config.experts_dir { - let nested = std::path::Path::new(experts_dir).join(¶ms.expert).join(format!("{}.mq5", params.expert)); + let nested = std::path::Path::new(experts_dir) + .join(¶ms.expert) + .join(format!("{}.mq5", params.expert)); if nested.exists() { format!("Experts\\{}\\{}.ex5", params.expert, params.expert) } else { @@ -1242,32 +1487,48 @@ impl BacktestPipeline { format!("Experts\\{}.ex5", params.expert) }; - let set_file_line = params.set_file.as_ref() - .map(|p| format!("ExpertParameters={}\n", Self::ini_safe(p))) - .unwrap_or_default(); - - let updates: &[(&str, String)] = &[ - ("Expert", Self::ini_safe(&expert_path)), - ("Symbol", Self::ini_safe(¶ms.symbol)), - ("Period", period.to_string()), - ("DateRange", "3".into()), - ("DateFrom", from_ts.to_string()), - ("DateTo", to_ts.to_string()), - ("Visualization", "0".into()), - ("Execution", "10".into()), - ("Currency", currency.into()), - ("Leverage", params.leverage.to_string()), - ("Deposit", format!("{:.2}", params.deposit)), - ("TicksMode", params.model.to_string()), - ("PipsCalculation", "1".into()), - ("OptMode", "0".into()), - ("Report", format!("reports\\{}.htm", report_id)), - ("ReplaceReport", "1".into()), - ("ShutdownTerminal", if params.shutdown { "1" } else { "0" }.into()), + let mut updates: Vec<(&str, String)> = vec![ + ("Expert", Self::ini_safe(&expert_path)), + ("Symbol", Self::ini_safe(¶ms.symbol)), + ("Period", period.to_string()), + ("DateRange", "3".into()), + ("DateFrom", from_ts.to_string()), + ("DateTo", to_ts.to_string()), + ("Visualization", "0".into()), + ("Execution", "10".into()), + ("Currency", currency.into()), + ("Leverage", params.leverage.to_string()), + ("Deposit", format!("{:.2}", params.deposit)), + ("TicksMode", params.model.to_string()), + ("PipsCalculation", "1".into()), + ("OptMode", "0".into()), + ("Report", format!("reports\\{}.htm", report_id)), + ("ReplaceReport", "1".into()), + ( + "ShutdownTerminal", + if params.shutdown { "1" } else { "0" }.into(), + ), ]; - let updated = Self::patch_ini_section(&text, "Tester", updates) + &set_file_line; - let bom_utf16: Vec = [0xFF, 0xFE].iter().copied() + // As with build_backtest_ini: stage into the tester's own profile dir and + // reference by relative filename - a raw host path is not resolvable by + // MT5 under Wine. Previously this was appended *after* the fully patched + // ini text instead of being placed inside [Tester], so it landed outside + // any section and MT5 silently ignored it regardless of path validity. + if let Some(set_file) = ¶ms.set_file { + let staged_name = match &self.config.tester_profiles_dir { + Some(tester_dir) => { + stage_set_file_for_tester(set_file, Path::new(tester_dir), ¶ms.expert)? + } + None => set_file.clone(), + }; + updates.push(("ExpertParameters", Self::ini_safe(&staged_name))); + } + + let updated = Self::patch_ini_section(&text, "Tester", &updates); + let bom_utf16: Vec = [0xFF, 0xFE] + .iter() + .copied() .chain(updated.encode_utf16().flat_map(|c| c.to_le_bytes())) .collect(); fs::write(&terminal_ini, bom_utf16)?; @@ -1351,19 +1612,25 @@ impl BacktestPipeline { // MT5.app's bundled wine64 doesn't reliably handle /config: arguments. let wine_bin = Path::new(wine_exe); let wine_root = wine_bin - .parent() // bin/ - .and_then(|p| p.parent()) // wine/ + .parent() // bin/ + .and_then(|p| p.parent()) // wine/ .map(|p| p.to_path_buf()) .ok_or_else(|| anyhow!("Cannot derive Wine root from wine_exe"))?; - let ext_libs = wine_root.join("lib").join("external"); + let ext_libs = wine_root.join("lib").join("external"); let wine_libs = wine_root.join("lib"); - let dyld = format!("{}:{}:/usr/lib:/usr/local/lib", - ext_libs.display(), wine_libs.display()); + let dyld = format!( + "{}:{}:/usr/lib:/usr/local/lib", + ext_libs.display(), + wine_libs.display() + ); // Use host path for the exe; use /config: with backslash-escaped path - let terminal_host = wine_prefix.join("drive_c") - .join("Program Files").join("MetaTrader 5").join("terminal64.exe"); + let terminal_host = wine_prefix + .join("drive_c") + .join("Program Files") + .join("MetaTrader 5") + .join("terminal64.exe"); // /config: triggers the Strategy Tester to auto-start. // terminal.ini is also patched with the same params as a belt-and-suspenders. @@ -1375,11 +1642,11 @@ impl BacktestPipeline { export WINEDEBUG='-all'\n\ nohup '{wine}' '{terminal}' '/config:{config}' \ >/dev/null 2>&1 &\n", - dyld = dyld, - prefix = wine_prefix.display(), - wine = wine_exe, + dyld = dyld, + prefix = wine_prefix.display(), + wine = wine_exe, terminal = terminal_host.display(), - config = config_win, + config = config_win, ); let script_path = std::env::temp_dir().join("mt5_backtest_launch.sh"); @@ -1394,7 +1661,10 @@ impl BacktestPipeline { } tracing::debug!("Wrote launch script: {}", script_path.display()); - tracing::info!("Launching MT5 via shell script (terminal.ini mode): {}", script_path.display()); + tracing::info!( + "Launching MT5 via shell script (terminal.ini mode): {}", + script_path.display() + ); let mut cmd = Command::new("/bin/sh"); cmd.arg(&script_path); return Ok(cmd); @@ -1415,8 +1685,12 @@ impl BacktestPipeline { /// The /config: format does NOT include the "Experts\" prefix. fn resolve_backtest_ini_expert_path(&self, expert: &str) -> String { if let Some(experts_dir) = &self.config.experts_dir { - let nested_ex5 = PathBuf::from(experts_dir).join(expert).join(format!("{}.ex5", expert)); - let nested_mq5 = PathBuf::from(experts_dir).join(expert).join(format!("{}.mq5", expert)); + let nested_ex5 = PathBuf::from(experts_dir) + .join(expert) + .join(format!("{}.ex5", expert)); + let nested_mq5 = PathBuf::from(experts_dir) + .join(expert) + .join(format!("{}.mq5", expert)); if nested_ex5.exists() || nested_mq5.exists() { return format!("{}\\{}.ex5", expert, expert); } @@ -1437,13 +1711,16 @@ impl BacktestPipeline { if let Some(password) = &self.config.backtest_password { ini.push_str(&format!("Password={}\n", password)); } - ini.push_str("\n"); + ini.push('\n'); } } ini.push_str("[Tester]\n"); // Expert path is relative to MQL5/Experts/ in the /config: format (no "Experts\" prefix). - ini.push_str(&format!("Expert={}\n", Self::ini_safe(&self.resolve_backtest_ini_expert_path(¶ms.expert)))); + ini.push_str(&format!( + "Expert={}\n", + Self::ini_safe(&self.resolve_backtest_ini_expert_path(¶ms.expert)) + )); ini.push_str(&format!("Symbol={}\n", Self::ini_safe(¶ms.symbol))); ini.push_str(&format!("Period={}\n", Self::ini_safe(¶ms.timeframe))); ini.push_str("Optimization=0\n"); @@ -1452,17 +1729,39 @@ impl BacktestPipeline { ini.push_str(&format!("ToDate={}\n", params.to_date)); ini.push_str("ForwardMode=0\n"); ini.push_str(&format!("Deposit={}\n", params.deposit)); - ini.push_str(&format!("Currency={}\n", self.config.backtest_currency.as_ref().unwrap_or(&"USD".to_string()))); + ini.push_str(&format!( + "Currency={}\n", + self.config + .backtest_currency + .as_ref() + .unwrap_or(&"USD".to_string()) + )); ini.push_str("ProfitInPips=1\n"); ini.push_str(&format!("Leverage={}\n", params.leverage)); ini.push_str("Execution=10\n"); ini.push_str(&format!("Visual={}\n", if params.gui { "1" } else { "0" })); ini.push_str(&format!("Report=reports\\{}.htm\n", report_id)); ini.push_str("ReplaceReport=1\n"); - ini.push_str(&format!("ShutdownTerminal={}\n", if params.shutdown { "1" } else { "0" })); + ini.push_str(&format!( + "ShutdownTerminal={}\n", + if params.shutdown { "1" } else { "0" } + )); if let Some(set_file) = ¶ms.set_file { - ini.push_str(&format!("ExpertParameters={}\n", Self::ini_safe(set_file))); + // A raw host path here (e.g. a POSIX path under Wine) is not resolvable + // by MT5 - stage the file into the tester's own profile dir under the + // expert's canonical name and reference it by that relative filename + // instead, mirroring the (working) approach in optimization::optimizer. + if let Some(tester_dir) = &self.config.tester_profiles_dir { + let staged_name = + stage_set_file_for_tester(set_file, Path::new(tester_dir), ¶ms.expert)?; + ini.push_str(&format!( + "ExpertParameters={}\n", + Self::ini_safe(&staged_name) + )); + } else { + ini.push_str(&format!("ExpertParameters={}\n", Self::ini_safe(set_file))); + } } Ok(ini) @@ -1486,12 +1785,16 @@ impl BacktestPipeline { tracing::info!("Stopping existing MT5 instance..."); // SIGKILL immediately — MT5 holds no state we care about preserving. for pat in &patterns { - let _ = Command::new("pkill").args(["-KILL", "-f", pat.as_str()]).output(); + let _ = Command::new("pkill") + .args(["-KILL", "-f", pat.as_str()]) + .output(); } // Also kill wineserver so the Wine prefix is fully reset before relaunch. // If wineserver is still alive when the new MT5 spawns, the new Wine // instance may attach to the dying server and never enter tester mode. - let _ = Command::new("pkill").args(["-KILL", "-f", "wineserver"]).output(); + let _ = Command::new("pkill") + .args(["-KILL", "-f", "wineserver"]) + .output(); // Poll until wineserver is actually gone (max 10 s) rather than sleeping // a fixed amount. On macOS wineserver cleanup varies from 1 s to 6+ s. @@ -1541,7 +1844,9 @@ impl BacktestPipeline { let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries .filter_map(|e| e.ok()) .filter(|e| { - let ext = e.path().extension() + let ext = e + .path() + .extension() .and_then(|x| x.to_str()) .unwrap_or("") .to_lowercase(); @@ -1549,7 +1854,11 @@ impl BacktestPipeline { }) .filter_map(|e| { let mtime = e.metadata().ok()?.modified().ok()?; - if mtime >= since { Some((mtime, e.path())) } else { None } + if mtime >= since { + Some((mtime, e.path())) + } else { + None + } }) .collect(); @@ -1567,10 +1876,7 @@ impl BacktestPipeline { ] } else { // Linux: MT5 always runs as a Wine process - vec![ - "terminal64\\.exe".to_string(), - "metatrader".to_string(), - ] + vec!["terminal64\\.exe".to_string(), "metatrader".to_string()] } } @@ -1580,7 +1886,13 @@ impl BacktestPipeline { let _ = fs::write(log_path, line); } - async fn save_metadata(&self, params: &BacktestParams, report_dir: &Path, duration: i64, no_trades: bool) -> Result<()> { + async fn save_metadata( + &self, + params: &BacktestParams, + report_dir: &Path, + duration: i64, + no_trades: bool, + ) -> Result<()> { let metadata = PipelineMetadata { expert: params.expert.clone(), symbol: params.symbol.clone(), @@ -1588,15 +1900,25 @@ impl BacktestPipeline { from_date: params.from_date.clone(), to_date: params.to_date.clone(), deposit: params.deposit as f64, - currency: self.config.backtest_currency.clone().unwrap_or_else(|| "USD".to_string()), + currency: self + .config + .backtest_currency + .clone() + .unwrap_or_else(|| "USD".to_string()), model: params.model as i32, leverage: params.leverage as i32, set_file: params.set_file.clone(), report_dir: report_dir.to_string_lossy().to_string(), duration_seconds: duration, files: FilePaths { - metrics: report_dir.join("metrics.json").to_string_lossy().to_string(), - analysis: report_dir.join("analysis.json").to_string_lossy().to_string(), + metrics: report_dir + .join("metrics.json") + .to_string_lossy() + .to_string(), + analysis: report_dir + .join("analysis.json") + .to_string_lossy() + .to_string(), }, no_trades, }; diff --git a/src/storage/database.rs b/src/storage/database.rs index c6429b5..e597bcf 100644 --- a/src/storage/database.rs +++ b/src/storage/database.rs @@ -68,7 +68,8 @@ impl ReportDb { std::fs::create_dir_all(parent)?; } let conn = self.connect()?; - conn.execute_batch(" + conn.execute_batch( + " CREATE TABLE IF NOT EXISTS deals ( id INTEGER PRIMARY KEY AUTOINCREMENT, report_id TEXT NOT NULL REFERENCES reports(id) ON DELETE CASCADE, @@ -119,7 +120,8 @@ impl ReportDb { CREATE INDEX IF NOT EXISTS idx_reports_expert ON reports(expert); CREATE INDEX IF NOT EXISTS idx_reports_symbol ON reports(symbol); CREATE INDEX IF NOT EXISTS idx_reports_created_at ON reports(created_at DESC); - ")?; + ", + )?; Ok(()) } @@ -355,8 +357,7 @@ impl ReportDb { param_vals.push(id.to_string()); let sql = format!("UPDATE reports SET {} WHERE id = ?", sets.join(", ")); - let changed = - conn.execute(&sql, rusqlite::params_from_iter(param_vals.iter()))?; + let changed = conn.execute(&sql, rusqlite::params_from_iter(param_vals.iter()))?; Ok(changed > 0) } @@ -366,8 +367,7 @@ impl ReportDb { keep_last: usize, ) -> Result)>> { let conn = self.connect()?; - let count: usize = - conn.query_row("SELECT COUNT(*) FROM reports", [], |r| r.get(0))?; + let count: usize = conn.query_row("SELECT COUNT(*) FROM reports", [], |r| r.get(0))?; if count <= keep_last { return Ok(Vec::new()); @@ -417,7 +417,7 @@ impl ReportDb { 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 ORDER BY created_at DESC LIMIT 1" + FROM reports ORDER BY created_at DESC LIMIT 1", )?; let entry = stmt @@ -468,7 +468,7 @@ impl ReportDb { 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 report_dir = ?" + FROM reports WHERE report_dir = ?", )?; let entry = stmt @@ -519,7 +519,7 @@ impl ReportDb { 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 = ?" + FROM reports WHERE id = ?", )?; let entry = stmt @@ -577,7 +577,9 @@ impl ReportDb { vec![(limit as i64).into()], ) } else { - let placeholders = tags.iter().enumerate() + let placeholders = tags + .iter() + .enumerate() .map(|(i, _)| format!("tags LIKE ?{}", i + 1)) .collect::>() .join(" OR "); @@ -587,9 +589,8 @@ impl ReportDb { placeholders, tags.len() + 1 ); - let mut p: Vec = tags.iter() - .map(|t| format!("%{}%", t).into()) - .collect(); + let mut p: Vec = + tags.iter().map(|t| format!("%{}%", t).into()).collect(); p.push((limit as i64).into()); (sql, p) }; @@ -644,7 +645,7 @@ impl ReportDb { 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 ?1 ORDER BY created_at DESC LIMIT ?2" + FROM reports WHERE notes LIKE ?1 ORDER BY created_at DESC LIMIT ?2", )?; let entries: Vec = stmt @@ -697,7 +698,7 @@ impl ReportDb { win_rate_pct, recovery_factor, deposit, currency, leverage, \ duration_seconds, tags, notes, verdict \ FROM reports WHERE (set_file_original LIKE ?1 OR set_snapshot_path LIKE ?1) \ - ORDER BY created_at DESC LIMIT ?2" + ORDER BY created_at DESC LIMIT ?2", )?; let entries: Vec = stmt @@ -900,8 +901,16 @@ impl ReportDb { 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"]; + 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)); } @@ -939,7 +948,10 @@ impl ReportDb { } let order = if ascending { "ASC" } else { "DESC" }; - sql.push_str(&format!(" ORDER BY {} {} LIMIT {}", sort_column, order, limit)); + sql.push_str(&format!( + " ORDER BY {} {} LIMIT {}", + sort_column, order, limit + )); let mut stmt = conn.prepare(&sql)?; let entries: Vec = stmt diff --git a/src/tools/definitions/analytics.rs b/src/tools/definitions/analytics.rs index 6514d08..4d839f6 100644 --- a/src/tools/definitions/analytics.rs +++ b/src/tools/definitions/analytics.rs @@ -1,7 +1,8 @@ use serde_json::{json, Value}; // Common description suffix for all analytics tools -const REPORT_HINT: &str = "report_id (preferred), report_dir (legacy path), or omit for latest report."; +const REPORT_HINT: &str = + "report_id (preferred), report_dir (legacy path), or omit for latest report."; pub fn tool_analyze_report() -> Value { json!({ diff --git a/src/tools/definitions/experts.rs b/src/tools/definitions/experts.rs index af45935..ac7f324 100644 --- a/src/tools/definitions/experts.rs +++ b/src/tools/definitions/experts.rs @@ -78,12 +78,12 @@ pub fn tool_search_indicators() -> Value { "inputSchema": { "type": "object", "properties": { - "pattern": { - "type": "string", - "description": "Search pattern (case-insensitive substring match)" + "pattern": { + "type": "string", + "description": "Search pattern (case-insensitive substring match)" }, - "include_builtin": { - "type": "boolean", + "include_builtin": { + "type": "boolean", "description": "Include built-in MT5 indicators in search", "default": false } @@ -100,9 +100,9 @@ pub fn tool_search_scripts() -> Value { "inputSchema": { "type": "object", "properties": { - "pattern": { - "type": "string", - "description": "Search pattern (case-insensitive substring match)" + "pattern": { + "type": "string", + "description": "Search pattern (case-insensitive substring match)" } }, "required": ["pattern"] @@ -117,13 +117,13 @@ pub fn tool_copy_indicator_to_project() -> Value { "inputSchema": { "type": "object", "properties": { - "source_path": { - "type": "string", - "description": "Full source path to the indicator file (.mq5 or .ex5)" + "source_path": { + "type": "string", + "description": "Full source path to the indicator file (.mq5 or .ex5)" }, - "target_name": { - "type": "string", - "description": "Optional: Rename the file (without extension). If not provided, uses original name" + "target_name": { + "type": "string", + "description": "Optional: Rename the file (without extension). If not provided, uses original name" } }, "required": ["source_path"] @@ -138,13 +138,13 @@ pub fn tool_copy_script_to_project() -> Value { "inputSchema": { "type": "object", "properties": { - "source_path": { - "type": "string", - "description": "Full source path to the script file (.mq5 or .ex5)" + "source_path": { + "type": "string", + "description": "Full source path to the script file (.mq5 or .ex5)" }, - "target_name": { - "type": "string", - "description": "Optional: Rename the file (without extension). If not provided, uses original name" + "target_name": { + "type": "string", + "description": "Optional: Rename the file (without extension). If not provided, uses original name" } }, "required": ["source_path"] diff --git a/src/tools/definitions/mod.rs b/src/tools/definitions/mod.rs index 9c93377..063c08c 100644 --- a/src/tools/definitions/mod.rs +++ b/src/tools/definitions/mod.rs @@ -13,13 +13,13 @@ pub mod utility; pub fn get_tools_list() -> Value { let tools = vec![ // Backtest - Granular options - backtest::tool_run_backtest(), // Full pipeline: compile + clean + backtest + extract + analyze - backtest::tool_run_backtest_quick(), // Quick: skip compile, do clean + backtest + extract + analyze - backtest::tool_run_backtest_only(), // Minimal: skip compile, do clean + backtest + extract only - backtest::tool_launch_backtest(), // Fire-and-forget: compile + clean + launch MT5 - backtest::tool_get_backtest_status(), // Poll for completion - backtest::tool_get_tester_log(), // Live journal reading mid-backtest or after - backtest::tool_run_rolling_backtest(), // Rolling backtest: multiple sequential weeks + backtest::tool_run_backtest(), // Full pipeline: compile + clean + backtest + extract + analyze + backtest::tool_run_backtest_quick(), // Quick: skip compile, do clean + backtest + extract + analyze + backtest::tool_run_backtest_only(), // Minimal: skip compile, do clean + backtest + extract only + backtest::tool_launch_backtest(), // Fire-and-forget: compile + clean + launch MT5 + backtest::tool_get_backtest_status(), // Poll for completion + backtest::tool_get_tester_log(), // Live journal reading mid-backtest or after + backtest::tool_run_rolling_backtest(), // Rolling backtest: multiple sequential weeks backtest::tool_cache_status(), backtest::tool_clean_cache(), // Optimization diff --git a/src/tools/handlers/analysis.rs b/src/tools/handlers/analysis.rs index 0345639..52c1175 100644 --- a/src/tools/handlers/analysis.rs +++ b/src/tools/handlers/analysis.rs @@ -1,13 +1,13 @@ -use anyhow::Result; -use serde_json::{json, Value}; -use std::collections::HashSet; -use std::fs; -use std::path::Path; use crate::analytics::DealAnalyzer; use crate::models::deals::Deal; use crate::models::metrics::Metrics; use crate::models::Config; use crate::storage::ReportDb; +use anyhow::Result; +use serde_json::{json, Value}; +use std::collections::HashSet; +use std::fs; +use std::path::Path; // ── Internal helpers ────────────────────────────────────────────────────────── @@ -41,10 +41,12 @@ fn resolve_report(args: &Value) -> Result<(Vec, Metrics, String)> { db.get_by_id(id)? .ok_or_else(|| anyhow::anyhow!("Report '{}' not found in DB", id))? } else if let Some(dir) = args.get("report_dir").and_then(|v| v.as_str()) { - db.get_by_report_dir(dir)? - .ok_or_else(|| anyhow::anyhow!( - "No DB entry for report_dir '{}'. This report may predate DB storage.", dir - ))? + db.get_by_report_dir(dir)?.ok_or_else(|| { + anyhow::anyhow!( + "No DB entry for report_dir '{}'. This report may predate DB storage.", + dir + ) + })? } else { db.get_latest()? .ok_or_else(|| anyhow::anyhow!("No reports in DB. Run a backtest first."))? @@ -72,25 +74,52 @@ fn prepare_analysis(args: &Value) -> Result<(Vec, Metrics, DealAnalyzer, S pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result { let (deals, metrics, analyzer, report_dir) = prepare_analysis(args)?; - let requested: Option> = args.get("analytics") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()); + let requested: Option> = + args.get("analytics").and_then(|v| v.as_array()).map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }); - let top_losses_limit = args.get("top_losses_limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; + let top_losses_limit = args + .get("top_losses_limit") + .and_then(|v| v.as_u64()) + .unwrap_or(10) as usize; let all = requested.is_none(); - let req = |name: &str| all || requested.as_ref().map(|s| s.contains(name)).unwrap_or(false); + let req = |name: &str| { + all || requested + .as_ref() + .map(|s| s.contains(name)) + .unwrap_or(false) + }; let mut result = json!({}); - if req("monthly_pnl") { result["monthly"] = json!(analyzer.monthly_pnl(&deals)); } - if req("drawdown_events") { result["dd_events"] = json!(analyzer.reconstruct_dd_events(&deals, &metrics)); } - if req("top_losses") { result["top_losses"] = json!(analyzer.top_losses(&deals, top_losses_limit)); } - if req("loss_sequences") { result["loss_sequences"] = json!(analyzer.loss_sequences(&deals)); } - if req("position_pairs") { result["position_pairs"] = json!(analyzer.position_pairs(&deals)); } - if req("direction_bias") { result["direction_bias"] = json!(analyzer.direction_bias(&deals)); } - if req("streak_analysis") { result["streak_analysis"] = json!(analyzer.streak_analysis(&deals)); } - if req("concurrent_peak") { result["concurrent_peak"] = json!(analyzer.concurrent_peak(&deals)); } + if req("monthly_pnl") { + result["monthly"] = json!(analyzer.monthly_pnl(&deals)); + } + if req("drawdown_events") { + result["dd_events"] = json!(analyzer.reconstruct_dd_events(&deals, &metrics)); + } + if req("top_losses") { + result["top_losses"] = json!(analyzer.top_losses(&deals, top_losses_limit)); + } + if req("loss_sequences") { + result["loss_sequences"] = json!(analyzer.loss_sequences(&deals)); + } + if req("position_pairs") { + result["position_pairs"] = json!(analyzer.position_pairs(&deals)); + } + if req("direction_bias") { + result["direction_bias"] = json!(analyzer.direction_bias(&deals)); + } + if req("streak_analysis") { + result["streak_analysis"] = json!(analyzer.streak_analysis(&deals)); + } + if req("concurrent_peak") { + result["concurrent_peak"] = json!(analyzer.concurrent_peak(&deals)); + } let analysis_path = Path::new(&report_dir).join("analysis.json"); fs::write(&analysis_path, serde_json::to_string_pretty(&result)?)?; @@ -132,78 +161,111 @@ pub async fn handle_compare_baseline(_config: &Config, args: &Value) -> Result Result { let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "monthly_pnl": analyzer.monthly_pnl(&deals) }))) + Ok(ok_response( + json!({ "success": true, "monthly_pnl": analyzer.monthly_pnl(&deals) }), + )) } pub async fn handle_analyze_drawdown_events(_config: &Config, args: &Value) -> Result { let (deals, metrics, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "drawdown_events": analyzer.reconstruct_dd_events(&deals, &metrics) }))) + Ok(ok_response( + json!({ "success": true, "drawdown_events": analyzer.reconstruct_dd_events(&deals, &metrics) }), + )) } pub async fn handle_analyze_top_losses(_config: &Config, args: &Value) -> Result { let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "limit": limit, "top_losses": analyzer.top_losses(&deals, limit) }))) + Ok(ok_response( + json!({ "success": true, "limit": limit, "top_losses": analyzer.top_losses(&deals, limit) }), + )) } pub async fn handle_analyze_loss_sequences(_config: &Config, args: &Value) -> Result { let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "loss_sequences": analyzer.loss_sequences(&deals) }))) + Ok(ok_response( + json!({ "success": true, "loss_sequences": analyzer.loss_sequences(&deals) }), + )) } pub async fn handle_analyze_position_pairs(_config: &Config, args: &Value) -> Result { let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "position_pairs": analyzer.position_pairs(&deals) }))) + Ok(ok_response( + json!({ "success": true, "position_pairs": analyzer.position_pairs(&deals) }), + )) } pub async fn handle_analyze_direction_bias(_config: &Config, args: &Value) -> Result { let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "direction_bias": analyzer.direction_bias(&deals) }))) + Ok(ok_response( + json!({ "success": true, "direction_bias": analyzer.direction_bias(&deals) }), + )) } pub async fn handle_analyze_streaks(_config: &Config, args: &Value) -> Result { let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "streak_analysis": analyzer.streak_analysis(&deals) }))) + Ok(ok_response( + json!({ "success": true, "streak_analysis": analyzer.streak_analysis(&deals) }), + )) } pub async fn handle_analyze_concurrent_peak(_config: &Config, args: &Value) -> Result { let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "concurrent_peak": analyzer.concurrent_peak(&deals) }))) + Ok(ok_response( + json!({ "success": true, "concurrent_peak": analyzer.concurrent_peak(&deals) }), + )) } pub async fn handle_analyze_profit_distribution(_config: &Config, args: &Value) -> Result { let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "profit_distribution": analyzer.profit_distribution(&deals) }))) + Ok(ok_response( + json!({ "success": true, "profit_distribution": analyzer.profit_distribution(&deals) }), + )) } pub async fn handle_analyze_time_performance(_config: &Config, args: &Value) -> Result { let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "time_performance": analyzer.time_performance(&deals) }))) + Ok(ok_response( + json!({ "success": true, "time_performance": analyzer.time_performance(&deals) }), + )) } -pub async fn handle_analyze_hold_time_distribution(_config: &Config, args: &Value) -> Result { +pub async fn handle_analyze_hold_time_distribution( + _config: &Config, + args: &Value, +) -> Result { let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "hold_time_analysis": analyzer.hold_time_analysis(&deals) }))) + Ok(ok_response( + json!({ "success": true, "hold_time_analysis": analyzer.hold_time_analysis(&deals) }), + )) } pub async fn handle_analyze_layer_performance(_config: &Config, args: &Value) -> Result { let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "layer_performance": analyzer.layer_performance(&deals) }))) + Ok(ok_response( + json!({ "success": true, "layer_performance": analyzer.layer_performance(&deals) }), + )) } pub async fn handle_analyze_volume_vs_profit(_config: &Config, args: &Value) -> Result { let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "volume_analysis": analyzer.volume_analysis(&deals) }))) + Ok(ok_response( + json!({ "success": true, "volume_analysis": analyzer.volume_analysis(&deals) }), + )) } pub async fn handle_analyze_costs(_config: &Config, args: &Value) -> Result { let (deals, _, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "cost_analysis": analyzer.cost_analysis(&deals) }))) + Ok(ok_response( + json!({ "success": true, "cost_analysis": analyzer.cost_analysis(&deals) }), + )) } pub async fn handle_analyze_efficiency(_config: &Config, args: &Value) -> Result { let (deals, metrics, analyzer, _) = prepare_analysis(args)?; - Ok(ok_response(json!({ "success": true, "efficiency_analysis": analyzer.efficiency_analysis(&deals, &metrics) }))) + Ok(ok_response( + json!({ "success": true, "efficiency_analysis": analyzer.efficiency_analysis(&deals, &metrics) }), + )) } // ── Deal query handlers ─────────────────────────────────────────────────────── @@ -233,26 +295,59 @@ fn is_closed_trade(d: &Deal) -> bool { pub async fn handle_list_deals(_config: &Config, args: &Value) -> Result { let (deals, _, _, _) = prepare_analysis(args)?; - let deal_type = args.get("deal_type").and_then(|v| v.as_str()); + 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 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| { - if !is_closed_trade(d) { 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(s) = start_date { if d.time.as_str() < s { return false; } } - if let Some(e) = end_date { if d.time.as_str() > e { 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(); + let mut filtered: Vec<&Deal> = deals + .iter() + .filter(|d| { + if !is_closed_trade(d) { + 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(s) = start_date { + if d.time.as_str() < s { + return false; + } + } + if let Some(e) = end_date { + if d.time.as_str() > e { + 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(); filtered.sort_by(|a, b| b.time.cmp(&a.time)); filtered.truncate(limit); @@ -271,7 +366,8 @@ pub async fn handle_search_deals_by_comment(_config: &Config, args: &Value) -> R let (deals, _, _, _) = prepare_analysis(args)?; let query_lower = query.to_lowercase(); - let mut filtered: Vec<&Deal> = deals.iter() + let mut filtered: Vec<&Deal> = deals + .iter() .filter(|d| is_closed_trade(d) && d.comment.to_lowercase().contains(&query_lower)) .collect(); @@ -291,8 +387,11 @@ pub async fn handle_search_deals_by_magic(_config: &Config, args: &Value) -> Res let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize; let (deals, _, _, _) = prepare_analysis(args)?; - let mut filtered: Vec<&Deal> = deals.iter() - .filter(|d| is_closed_trade(d) && d.magic.as_ref().map(|m| m.contains(magic)).unwrap_or(false)) + let mut filtered: Vec<&Deal> = deals + .iter() + .filter(|d| { + is_closed_trade(d) && d.magic.as_ref().map(|m| m.contains(magic)).unwrap_or(false) + }) .collect(); filtered.sort_by(|a, b| b.time.cmp(&a.time)); @@ -307,4 +406,6 @@ pub async fn handle_search_deals_by_magic(_config: &Config, args: &Value) -> Res } #[allow(dead_code)] -fn _err_response_available() { let _ = err_response(""); } +fn _err_response_available() { + let _ = err_response(""); +} diff --git a/src/tools/handlers/backtest.rs b/src/tools/handlers/backtest.rs index a25aa84..ccfd2f5 100644 --- a/src/tools/handlers/backtest.rs +++ b/src/tools/handlers/backtest.rs @@ -1,3 +1,6 @@ +use crate::models::report::BacktestJob; +use crate::models::Config; +use crate::pipeline::backtest::{BacktestParams, BacktestPipeline}; use anyhow::Result; use chrono::Datelike; use serde_json::{json, Value}; @@ -5,9 +8,6 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use tokio::time::Duration; -use crate::models::Config; -use crate::models::report::BacktestJob; -use crate::pipeline::backtest::{BacktestParams, BacktestPipeline}; /// Pre-flight check result for backtest readiness #[derive(Debug)] @@ -23,16 +23,18 @@ impl BacktestPreflight { let account = config.current_account(); let server = account.as_ref().map(|a| a.server.clone()); let available_symbols = config.discover_symbols_for_active_account(); - + let ea_exists = if let Some(experts_dir) = &config.experts_dir { let mq5_path = std::path::Path::new(experts_dir).join(format!("{}.mq5", expert)); let ex5_path = std::path::Path::new(experts_dir).join(format!("{}.ex5", expert)); - let subdir_mq5 = std::path::Path::new(experts_dir).join(expert).join(format!("{}.mq5", expert)); + let subdir_mq5 = std::path::Path::new(experts_dir) + .join(expert) + .join(format!("{}.mq5", expert)); mq5_path.exists() || ex5_path.exists() || subdir_mq5.exists() } else { false }; - + Self { account, available_symbols, @@ -40,7 +42,7 @@ impl BacktestPreflight { server, } } - + #[allow(dead_code)] fn is_ready(&self) -> bool { self.account.is_some() && !self.available_symbols.is_empty() && self.ea_exists @@ -48,17 +50,22 @@ impl BacktestPreflight { } pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result { - let expert = args.get("expert") + let expert = args + .get("expert") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("expert is required"))?; // Run pre-flight check let preflight = BacktestPreflight::check(config, expert); - + // Get active account context for error messages let active_server = preflight.server.as_deref().unwrap_or("unknown"); - let active_login = preflight.account.as_ref().map(|a| a.login.as_str()).unwrap_or("unknown"); - + let active_login = preflight + .account + .as_ref() + .map(|a| a.login.as_str()) + .unwrap_or("unknown"); + // Check account session first if preflight.account.is_none() { return Ok(json!({ @@ -70,45 +77,50 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result "isError": true })); } - + // Symbol pre-flight with account context - let requested_symbol = args.get("symbol") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let requested_symbol = args.get("symbol").and_then(|v| v.as_str()).unwrap_or(""); // No tester data at all → hard fail with helpful context - let no_symbols_error = || json!({ - "content": [{ "type": "text", "text": json!({ - "error": format!("No symbols available for backtesting on server '{}'.", active_server), - "account": { "login": active_login, "server": active_server }, - "hint": "Open MT5 → View → Strategy Tester → download history for at least one symbol.", - "pre_check": "no_symbols" - }).to_string() }], - "isError": true - }); + let no_symbols_error = || { + json!({ + "content": [{ "type": "text", "text": json!({ + "error": format!("No symbols available for backtesting on server '{}'.", active_server), + "account": { "login": active_login, "server": active_server }, + "hint": "Open MT5 → View → Strategy Tester → download history for at least one symbol.", + "pre_check": "no_symbols" + }).to_string() }], + "isError": true + }) + }; let symbol: String = if requested_symbol.is_empty() { // No symbol requested — use config default or first available tester symbol. let candidate = config.backtest_symbol.as_deref().unwrap_or(""); if candidate.is_empty() { - preflight.available_symbols.first() + preflight + .available_symbols + .first() .cloned() - .ok_or(() - ).unwrap_or_else(|_| return String::new()) + .unwrap_or_default() } else { match Config::resolve_symbol(candidate, &preflight.available_symbols) { Some(resolved) => { if resolved != candidate { tracing::warn!( "Config symbol '{}' not in tester data for '{}'; using '{}' instead", - candidate, active_server, resolved + candidate, + active_server, + resolved ); } resolved.to_string() } None => { // Config default has no tester data — pick first available - preflight.available_symbols.first() + preflight + .available_symbols + .first() .cloned() .unwrap_or_default() } @@ -128,7 +140,9 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result // Fuzzy match — proceed with the corrected symbol, surface the substitution tracing::warn!( "Symbol '{}' not in tester data for '{}'; substituting '{}'", - requested_symbol, active_server, resolved + requested_symbol, + active_server, + resolved ); resolved.to_string() } @@ -157,7 +171,7 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result if symbol.is_empty() { return Ok(no_symbols_error()); } - + // EA existence check with context if !preflight.ea_exists { return Ok(json!({ @@ -186,20 +200,48 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result symbol: symbol.to_string(), from_date: from_date.to_string(), to_date: to_date.to_string(), - timeframe: args.get("timeframe").and_then(|v| v.as_str()).unwrap_or("M5").to_string(), - deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32, + timeframe: args + .get("timeframe") + .and_then(|v| v.as_str()) + .unwrap_or("M5") + .to_string(), + deposit: args + .get("deposit") + .and_then(|v| v.as_u64()) + .unwrap_or(10000) as u32, model: args.get("model").and_then(|v| v.as_u64()).unwrap_or(0) as u8, leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32, - set_file: args.get("set_file").and_then(|v| v.as_str()).map(|s| s.to_string()), - skip_compile: args.get("skip_compile").and_then(|v| v.as_bool()).unwrap_or(false), - skip_clean: args.get("skip_clean").and_then(|v| v.as_bool()).unwrap_or(false), - skip_analyze: args.get("skip_analyze").and_then(|v| v.as_bool()).unwrap_or(false), + set_file: args + .get("set_file") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + skip_compile: args + .get("skip_compile") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + skip_clean: args + .get("skip_clean") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + skip_analyze: args + .get("skip_analyze") + .and_then(|v| v.as_bool()) + .unwrap_or(false), deep_analyze: args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false), - shutdown: args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(true), - kill_existing: args.get("kill_existing").and_then(|v| v.as_bool()).unwrap_or(false), + shutdown: args + .get("shutdown") + .and_then(|v| v.as_bool()) + .unwrap_or(true), + kill_existing: args + .get("kill_existing") + .and_then(|v| v.as_bool()) + .unwrap_or(false), timeout: args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900), gui: args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false), - startup_delay_secs: args.get("startup_delay_secs").and_then(|v| v.as_u64()).unwrap_or(0), + startup_delay_secs: args + .get("startup_delay_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(0), inactivity_kill_secs: args.get("inactivity_kill_secs").and_then(|v| v.as_u64()), }; @@ -217,7 +259,10 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result })) } -pub async fn handle_run_backtest_quick(handler: &crate::tools::handlers::ToolHandler, args: &Value) -> Result { +pub async fn handle_run_backtest_quick( + handler: &crate::tools::handlers::ToolHandler, + args: &Value, +) -> Result { // Quick backtest: skip compile, clean → launch → background monitor → return job. // Uses the fire-and-forget launch path so the MCP response is returned immediately // and the result is available via get_backtest_status / get_latest_report once done. @@ -229,7 +274,10 @@ pub async fn handle_run_backtest_quick(handler: &crate::tools::handlers::ToolHan handle_launch_backtest(handler, &args).await } -pub async fn handle_run_backtest_only(handler: &crate::tools::handlers::ToolHandler, args: &Value) -> Result { +pub async fn handle_run_backtest_only( + handler: &crate::tools::handlers::ToolHandler, + args: &Value, +) -> Result { // Backtest only: skip compile and analyze — launch and return job immediately. let mut args = args.clone(); if let Some(obj) = args.as_object_mut() { @@ -239,14 +287,18 @@ pub async fn handle_run_backtest_only(handler: &crate::tools::handlers::ToolHand handle_launch_backtest(handler, &args).await } -pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandler, args: &Value) -> Result { - let expert = args.get("expert") +pub async fn handle_launch_backtest( + handler: &crate::tools::handlers::ToolHandler, + args: &Value, +) -> Result { + let expert = args + .get("expert") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("expert is required"))?; // Run pre-flight check let preflight = BacktestPreflight::check(&handler.config, expert); - + // Check account session if preflight.account.is_none() { return Ok(json!({ @@ -257,17 +309,19 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle "isError": true })); } - + // Get symbol — resolve against actual tester data (same logic as handle_run_backtest) let active_server = preflight.server.as_deref().unwrap_or("unknown"); - let requested_symbol = args.get("symbol") - .and_then(|v| v.as_str()) - .unwrap_or(""); + let requested_symbol = args.get("symbol").and_then(|v| v.as_str()).unwrap_or(""); let symbol: String = if requested_symbol.is_empty() { let candidate = handler.config.backtest_symbol.as_deref().unwrap_or(""); if candidate.is_empty() { - preflight.available_symbols.first().cloned().unwrap_or_default() + preflight + .available_symbols + .first() + .cloned() + .unwrap_or_default() } else { Config::resolve_symbol(candidate, &preflight.available_symbols) .map(|s| s.to_string()) @@ -280,7 +334,9 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle if resolved != requested_symbol { tracing::warn!( "launch_backtest: symbol '{}' not in tester data for '{}'; using '{}'", - requested_symbol, active_server, resolved + requested_symbol, + active_server, + resolved ); } resolved.to_string() @@ -303,7 +359,7 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle } } }; - + // EA existence check if !preflight.ea_exists { return Ok(json!({ @@ -331,13 +387,29 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle symbol: symbol.to_string(), from_date: from_date.to_string(), to_date: to_date.to_string(), - timeframe: args.get("timeframe").and_then(|v| v.as_str()).unwrap_or("M5").to_string(), - deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32, + timeframe: args + .get("timeframe") + .and_then(|v| v.as_str()) + .unwrap_or("M5") + .to_string(), + deposit: args + .get("deposit") + .and_then(|v| v.as_u64()) + .unwrap_or(10000) as u32, model: args.get("model").and_then(|v| v.as_u64()).unwrap_or(0) as u8, leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32, - set_file: args.get("set_file").and_then(|v| v.as_str()).map(|s| s.to_string()), - skip_compile: args.get("skip_compile").and_then(|v| v.as_bool()).unwrap_or(false), - skip_clean: args.get("skip_clean").and_then(|v| v.as_bool()).unwrap_or(false), + set_file: args + .get("set_file") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + skip_compile: args + .get("skip_compile") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + skip_clean: args + .get("skip_clean") + .and_then(|v| v.as_bool()) + .unwrap_or(false), skip_analyze: true, // Not needed for launch mode deep_analyze: false, // On Wine/macOS, ShutdownTerminal=1 does NOT reliably cause terminal64.exe to exit. @@ -345,11 +417,17 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle // tester log goes quiet, then polls for the HTML report for 30 s, then kills MT5. // If no inactivity_kill_secs is given (default None → disabled), the monitor relies // solely on timeout (900 s) or natural MT5 exit for completion detection. - shutdown: args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(true), + shutdown: args + .get("shutdown") + .and_then(|v| v.as_bool()) + .unwrap_or(true), kill_existing: false, timeout: args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900), gui: args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false), - startup_delay_secs: args.get("startup_delay_secs").and_then(|v| v.as_u64()).unwrap_or(0), + startup_delay_secs: args + .get("startup_delay_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(0), inactivity_kill_secs: args.get("inactivity_kill_secs").and_then(|v| v.as_u64()), }; @@ -378,12 +456,13 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle } pub async fn handle_run_rolling_backtest(config: &Config, args: &Value) -> Result { - let expert = args.get("expert") + let expert = args + .get("expert") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("expert is required"))?; let weeks_count = args.get("weeks").and_then(|v| v.as_u64()).unwrap_or(4) as i64; - if weeks_count < 1 || weeks_count > 52 { + if !(1..=52).contains(&weeks_count) { return Ok(json!({ "content": [{ "type": "text", "text": "weeks must be between 1 and 52".to_string() }], "isError": true @@ -418,7 +497,11 @@ pub async fn handle_run_rolling_backtest(config: &Config, args: &Value) -> Resul std::cmp::min(week_end_raw, end) }; let label = format!("Week {}", week_idx); - weeks.push((label, current.format("%Y.%m.%d").to_string(), week_end.format("%Y.%m.%d").to_string())); + weeks.push(( + label, + current.format("%Y.%m.%d").to_string(), + week_end.format("%Y.%m.%d").to_string(), + )); current = week_end + chrono::Duration::days(1); } weeks @@ -436,30 +519,63 @@ pub async fn handle_run_rolling_backtest(config: &Config, args: &Value) -> Resul week_start.format("%b %d"), week_end.format("%b %d") ); - weeks.push((label, week_start.format("%Y.%m.%d").to_string(), week_end.format("%Y.%m.%d").to_string())); + weeks.push(( + label, + week_start.format("%Y.%m.%d").to_string(), + week_end.format("%Y.%m.%d").to_string(), + )); } weeks }; - let symbol = args.get("symbol").and_then(|v| v.as_str()).unwrap_or("").to_string(); - let timeframe = args.get("timeframe").and_then(|v| v.as_str()).unwrap_or("M5").to_string(); - let deposit = args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32; + let symbol = args + .get("symbol") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let timeframe = args + .get("timeframe") + .and_then(|v| v.as_str()) + .unwrap_or("M5") + .to_string(); + let deposit = args + .get("deposit") + .and_then(|v| v.as_u64()) + .unwrap_or(10000) as u32; let model = args.get("model").and_then(|v| v.as_u64()).unwrap_or(0) as u8; let leverage: u32 = args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32; - let set_file = args.get("set_file").and_then(|v| v.as_str()).map(|s| s.to_string()); + let set_file = args + .get("set_file") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); let deep_analyze = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false); - let shutdown = args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(true); - let kill_existing = args.get("kill_existing").and_then(|v| v.as_bool()).unwrap_or(true); + let shutdown = args + .get("shutdown") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let kill_existing = args + .get("kill_existing") + .and_then(|v| v.as_bool()) + .unwrap_or(true); let timeout = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900); let gui = args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false); - let startup_delay_secs = args.get("startup_delay_secs").and_then(|v| v.as_u64()).unwrap_or(0); - let skip_compile_first = args.get("skip_compile").and_then(|v| v.as_bool()).unwrap_or(false); + let startup_delay_secs = args + .get("startup_delay_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let skip_compile_first = args + .get("skip_compile") + .and_then(|v| v.as_bool()) + .unwrap_or(false); // Create a rolling job report dir for status tracking let report_id = format!( "ROLLING_{}_{}_{}", expert, - weeks.first().map(|w| &w.1).unwrap_or(&"unknown".to_string()), + weeks + .first() + .map(|w| &w.1) + .unwrap_or(&"unknown".to_string()), weeks.last().map(|w| &w.2).unwrap_or(&"unknown".to_string()), ); let report_dir = config.reports_dir().join(&report_id); @@ -482,9 +598,12 @@ pub async fn handle_run_rolling_backtest(config: &Config, args: &Value) -> Resul // Save the weekly schedule for status polling let weeks_path = report_dir.join("weeks.json"); - fs::write(&weeks_path, serde_json::to_string_pretty(&json!({ - "weeks": weeks.iter().map(|(l, f, t)| json!({"label": l, "from_date": f, "to_date": t})).collect::>() - }))?)?; + fs::write( + &weeks_path, + serde_json::to_string_pretty(&json!({ + "weeks": weeks.iter().map(|(l, f, t)| json!({"label": l, "from_date": f, "to_date": t})).collect::>() + }))?, + )?; // Spawn background task to run all weeks sequentially let config_clone = config.clone(); @@ -514,7 +633,9 @@ pub async fn handle_run_rolling_backtest(config: &Config, args: &Value) -> Resul startup_delay_secs, skip_compile_first, weeks_clone, - ).await { + ) + .await + { tracing::error!("Rolling backtest failed: {}", e); } }); @@ -533,6 +654,9 @@ pub async fn handle_run_rolling_backtest(config: &Config, args: &Value) -> Resul })) } +// Internal helper: parameters mirror the run_rolling_backtest tool inputs 1:1 and are +// passed through to per-week launches; a struct would only relocate the argument list. +#[allow(clippy::too_many_arguments)] async fn run_rolling_weeks_sequential( config: Config, report_dir: PathBuf, @@ -552,11 +676,7 @@ async fn run_rolling_weeks_sequential( skip_compile_first: bool, weeks: Vec<(String, String, String)>, ) -> Result<()> { - let pipeline = if cfg!(debug_assertions) { - BacktestPipeline::new(config.clone()) - } else { - BacktestPipeline::new(config.clone()) - }; + let pipeline = BacktestPipeline::new(config.clone()); let progress_log = report_dir.join("progress.log"); // Clear stale results @@ -577,10 +697,26 @@ async fn run_rolling_weeks_sequential( for (idx, (label, from_date, to_date)) in weeks.iter().enumerate() { let skip_compile = if idx == 0 { skip_compile_first } else { true }; - fs::write(&progress_log, format!("WEEK {}: {} ({} -> {})\n", idx + 1, label, from_date, to_date)) - .unwrap_or(()); + fs::write( + &progress_log, + format!( + "WEEK {}: {} ({} -> {})\n", + idx + 1, + label, + from_date, + to_date + ), + ) + .unwrap_or(()); - tracing::info!("Rolling week {}/{}: {} ({} -> {})", idx + 1, weeks.len(), label, from_date, to_date); + tracing::info!( + "Rolling week {}/{}: {} ({} -> {})", + idx + 1, + weeks.len(), + label, + from_date, + to_date + ); // Clean slate before each week for pat in &["MetaTrader 5.app", "terminal64.exe", "wineserver"] { @@ -614,7 +750,12 @@ async fn run_rolling_weeks_sequential( let job = match pipeline.launch_backtest(params).await { Ok(j) => j, Err(e) => { - tracing::error!("Rolling week {}/{} launch failed: {}", idx + 1, weeks.len(), e); + tracing::error!( + "Rolling week {}/{} launch failed: {}", + idx + 1, + weeks.len(), + e + ); week_results.push(json!({ "label": label, "from_date": from_date, "to_date": to_date, "success": false, "error": format!("launch failed: {}", e) @@ -632,15 +773,24 @@ async fn run_rolling_weeks_sequential( // Check for metrics.json (written after extraction, including journal fallback) let metrics_path = week_report_dir.join("metrics.json"); - if metrics_path.exists() { break; } + if metrics_path.exists() { + break; + } let jp = week_report_dir.join("job.json"); if let Ok(content) = fs::read_to_string(&jp) { if let Ok(j) = serde_json::from_str::(&content) { if let Some(ref s) = j.status { - if s == "completed" || s == "completed_no_html" { break; } + if s == "completed" || s == "completed_no_html" { + break; + } if s == "failed" || s == "timeout" || s == "timeout_inactive" { - tracing::warn!("Rolling week {}/{} background status: {}", idx + 1, weeks.len(), s); + tracing::warn!( + "Rolling week {}/{} background status: {}", + idx + 1, + weeks.len(), + s + ); break; } } @@ -648,7 +798,12 @@ async fn run_rolling_weeks_sequential( } if poll_start.elapsed() > max_wait { - tracing::warn!("Rolling week {}/{} timed out after {}s", idx + 1, weeks.len(), timeout); + tracing::warn!( + "Rolling week {}/{} timed out after {}s", + idx + 1, + weeks.len(), + timeout + ); break; } } @@ -660,16 +815,36 @@ async fn run_rolling_weeks_sequential( let metrics_path = week_report_dir.join("metrics.json"); if let Ok(content) = fs::read_to_string(&metrics_path) { if let Ok(metrics) = serde_json::from_str::(&content) { - let np = metrics.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0); - let dd = metrics.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0); - let trades = metrics.get("total_trades").and_then(|v| v.as_u64()).unwrap_or(0); - let pf = metrics.get("profit_factor").and_then(|v| v.as_f64()).unwrap_or(0.0); + let np = metrics + .get("net_profit") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let dd = metrics + .get("max_dd_pct") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let trades = metrics + .get("total_trades") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let pf = metrics + .get("profit_factor") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); total_net_profit += np; - if dd > max_drawdown { max_drawdown = dd; } + if dd > max_drawdown { + max_drawdown = dd; + } total_trades += trades; - tracing::info!("Rolling week {}/{} done: profit={:.2}, dd={:.1}%", idx + 1, weeks.len(), np, dd); + tracing::info!( + "Rolling week {}/{} done: profit={:.2}, dd={:.1}%", + idx + 1, + weeks.len(), + np, + dd + ); week_results.push(json!({ "label": label, "from_date": from_date, "to_date": to_date, @@ -715,19 +890,25 @@ async fn run_rolling_weeks_sequential( } fs::write(&progress_log, "DONE\n").unwrap_or(()); - tracing::info!("Rolling backtest completed: {} weeks, profit={:.2}, max_dd={:.1}%", week_results.len(), total_net_profit, max_drawdown); + tracing::info!( + "Rolling backtest completed: {} weeks, profit={:.2}, max_dd={:.1}%", + week_results.len(), + total_net_profit, + max_drawdown + ); Ok(()) } pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Result { - let report_dir = args.get("report_dir") + let report_dir = args + .get("report_dir") .and_then(|v| v.as_str()) .unwrap_or("latest"); let report_path = Path::new(report_dir); let progress_file = report_path.join("progress.log"); let job_file = report_path.join("job.json"); - + // Load job info if available let job: Option = if job_file.exists() { fs::read_to_string(&job_file) @@ -736,12 +917,13 @@ pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Resul } else { None }; - + // Check progress log for stage let (stage, progress_lines) = if progress_file.exists() { if let Ok(content) = fs::read_to_string(&progress_file) { let lines: Vec<&str> = content.lines().collect(); - let last_stage = lines.last() + let last_stage = lines + .last() .and_then(|l| l.split_whitespace().next()) .unwrap_or("UNKNOWN"); (last_stage.to_string(), lines.len()) @@ -751,12 +933,13 @@ pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Resul } else { ("NOT_STARTED".to_string(), 0) }; - + // Check if MT5 is running let mt5_running = is_mt5_running(); - + // Check if report file exists (still on disk — it's deleted after extraction) - let report_found = job.as_ref() + let report_found = job + .as_ref() .map(|j| Path::new(&j.expected_report_path).exists()) .unwrap_or(false); @@ -766,18 +949,15 @@ pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Resul // Read the authoritative status written by the background monitor into job.json. // This avoids false "failed" when the HTML was extracted+deleted (report_found=false) // or when journal extraction ran instead of HTML extraction. - let job_status = job.as_ref() - .and_then(|j| j.status.as_deref()) - .unwrap_or(""); + let job_status = job.as_ref().and_then(|j| j.status.as_deref()).unwrap_or(""); let monitor_says_complete = matches!(job_status, "completed" | "completed_no_html"); - let monitor_says_failed = matches!(job_status, "failed" | "timeout" | "timeout_inactive"); + let monitor_says_failed = matches!(job_status, "failed" | "timeout" | "timeout_inactive"); - let is_complete = monitor_says_complete - || stage == "DONE" - || (report_found && metrics_exists); + let is_complete = monitor_says_complete || stage == "DONE" || (report_found && metrics_exists); // Calculate elapsed time if job exists - let elapsed_seconds = job.as_ref() + let elapsed_seconds = job + .as_ref() .and_then(|j| { chrono::DateTime::parse_from_rfc3339(&j.launched_at) .ok() @@ -789,7 +969,11 @@ pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Resul let status_msg = if is_complete { "completed" } else if monitor_says_failed { - if job_status == "timeout" || job_status == "timeout_inactive" { "timeout" } else { "failed" } + if job_status == "timeout" || job_status == "timeout_inactive" { + "timeout" + } else { + "failed" + } } else if stage == "BACKTEST" && mt5_running { "running" } else if stage == "BACKTEST" && !mt5_running { @@ -848,7 +1032,7 @@ fn is_mt5_running() -> bool { } else { vec!["terminal64\\.exe", "metatrader"] }; - + patterns.iter().any(|pat| { Command::new("pgrep") .args(["-f", pat]) @@ -863,7 +1047,10 @@ fn is_mt5_running() -> bool { pub async fn handle_get_tester_log(config: &Config, args: &Value) -> Result { use crate::pipeline::backtest::BacktestPipeline; - let tail_lines = args.get("tail_lines").and_then(|v| v.as_u64()).unwrap_or(100) as usize; + let tail_lines = args + .get("tail_lines") + .and_then(|v| v.as_u64()) + .unwrap_or(100) as usize; let log_path = match BacktestPipeline::find_active_tester_agent_log(config) { Some(p) => p, @@ -893,7 +1080,8 @@ pub async fn handle_get_tester_log(config: &Config, args: &Value) -> Result = lines.iter() + let tail: Vec<&str> = lines + .iter() .rev() .take(tail_lines) .rev() @@ -901,7 +1089,9 @@ pub async fn handle_get_tester_log(config: &Config, args: &Value) -> Result = l.split_whitespace().collect(); // Format: XX 0 HH:MM:SS.mmm Core NN YYYY.MM.DD HH:MM:SS ... @@ -940,25 +1130,27 @@ pub async fn handle_get_tester_log(config: &Config, args: &Value) -> Result Result { - let cache_dir = config.tester_cache_dir.as_ref() - .map(|s| Path::new(s)) + let cache_dir = config + .tester_cache_dir + .as_ref() + .map(Path::new) .filter(|p| p.exists()); let mut total_size: u64 = 0; let mut symbols = Vec::new(); if let Some(dir) = cache_dir { - for entry in walkdir::WalkDir::new(dir).max_depth(2) { - if let Ok(entry) = entry { - if entry.file_type().is_dir() { - if let Some(name) = entry.file_name().to_str() { - symbols.push(name.to_string()); - } - } else { - if let Ok(meta) = entry.metadata() { - total_size += meta.len(); - } + for entry in walkdir::WalkDir::new(dir) + .max_depth(2) + .into_iter() + .flatten() + { + if entry.file_type().is_dir() { + if let Some(name) = entry.file_name().to_str() { + symbols.push(name.to_string()); } + } else if let Ok(meta) = entry.metadata() { + total_size += meta.len(); } } } @@ -976,24 +1168,27 @@ pub async fn handle_cache_status(config: &Config) -> Result { pub async fn handle_clean_cache(config: &Config, args: &Value) -> Result { let _symbol = args.get("symbol").and_then(|v| v.as_str()); - let dry_run = args.get("dry_run").and_then(|v| v.as_bool()).unwrap_or(false); + let dry_run = args + .get("dry_run") + .and_then(|v| v.as_bool()) + .unwrap_or(false); - let cache_dir = config.tester_cache_dir.as_ref() - .map(|s| Path::new(s)) + let cache_dir = config + .tester_cache_dir + .as_ref() + .map(Path::new) .filter(|p| p.exists()); let mut bytes_freed: u64 = 0; if let Some(dir) = cache_dir { - for entry in walkdir::WalkDir::new(dir) { - if let Ok(entry) = entry { - let path = entry.path(); - if path.extension().map(|e| e == "tst").unwrap_or(false) { - if let Ok(meta) = entry.metadata() { - bytes_freed += meta.len(); - if !dry_run { - let _ = fs::remove_file(path); - } + for entry in walkdir::WalkDir::new(dir).into_iter().flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "tst").unwrap_or(false) { + if let Ok(meta) = entry.metadata() { + bytes_freed += meta.len(); + if !dry_run { + let _ = fs::remove_file(path); } } } diff --git a/src/tools/handlers/experts.rs b/src/tools/handlers/experts.rs index f701e04..e6a9f47 100644 --- a/src/tools/handlers/experts.rs +++ b/src/tools/handlers/experts.rs @@ -1,21 +1,49 @@ +use crate::compile::MqlCompiler; +use crate::models::Config; use anyhow::Result; use serde_json::{json, Value}; use walkdir::WalkDir; -use crate::compile::MqlCompiler; -use crate::models::Config; const BUILTIN_INDICATORS: &[&str] = &[ - "Accelerator", "Accumulation", "ADX", "Alligator", "AO", "ATR", - "Bands", "Bears", "Bulls", "CCI", "DeMarker", "Envelopes", "Force", - "Fractals", "Gator", "Ichimoku", "MA", "MACD", "MFI", "Momentum", - "OBV", "OsMA", "RSI", "RVI", "SAR", "StdDev", "Stochastic", "WPR", + "Accelerator", + "Accumulation", + "ADX", + "Alligator", + "AO", + "ATR", + "Bands", + "Bears", + "Bulls", + "CCI", + "DeMarker", + "Envelopes", + "Force", + "Fractals", + "Gator", + "Ichimoku", + "MA", + "MACD", + "MFI", + "Momentum", + "OBV", + "OsMA", + "RSI", + "RVI", + "SAR", + "StdDev", + "Stochastic", + "WPR", ]; // ── Internal helpers ────────────────────────────────────────────────────────── /// Walk a MQL directory and return file entries matching an optional filter. /// `type_label` is included as a `"type"` field when provided (e.g. "custom"). -fn scan_mql_dir(dir: Option<&String>, filter: Option<&str>, type_label: Option<&str>) -> Vec { +fn scan_mql_dir( + dir: Option<&String>, + filter: Option<&str>, + type_label: Option<&str>, +) -> Vec { let Some(dir) = dir else { return Vec::new() }; let filter_lower = filter.map(|f| f.to_lowercase()); @@ -68,13 +96,16 @@ fn err_response(msg: impl std::fmt::Display) -> Value { fn copy_mql_to_project(config: &Config, args: &Value, default_fallback: &str) -> Result { use std::path::PathBuf; - let source_path = args.get("source_path") + let source_path = args + .get("source_path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("source_path is required"))?; let target_name = args.get("target_name").and_then(|v| v.as_str()); - let project_dir = config.project_dir.as_ref() + let project_dir = config + .project_dir + .as_ref() .map(PathBuf::from) .or_else(|| std::env::current_dir().ok()) .ok_or_else(|| anyhow::anyhow!("Cannot determine project directory"))?; @@ -89,7 +120,8 @@ fn copy_mql_to_project(config: &Config, args: &Value, default_fallback: &str) -> let ext = source.extension().and_then(|e| e.to_str()).unwrap_or("mq5"); let target_filename = match target_name { Some(name) => format!("{}.{}", name, ext), - None => source.file_name() + None => source + .file_name() .and_then(|n| n.to_str()) .map(|n| n.to_string()) .unwrap_or_else(|| format!("{}.{}", default_fallback, ext)), @@ -105,7 +137,10 @@ fn copy_mql_to_project(config: &Config, args: &Value, default_fallback: &str) -> "bytes_copied": bytes, }))), Err(e) => Ok(err_response( - serde_json::to_string(&json!({ "success": false, "error": format!("Failed to copy file: {}", e) })).unwrap_or_default() + serde_json::to_string( + &json!({ "success": false, "error": format!("Failed to copy file: {}", e) }), + ) + .unwrap_or_default(), )), } } @@ -123,7 +158,8 @@ pub async fn handle_list_experts(config: &Config, args: &Value) -> Result } pub async fn handle_search_experts(config: &Config, args: &Value) -> Result { - let pattern = args.get("pattern") + let pattern = args + .get("pattern") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("pattern is required"))?; let matches = scan_mql_dir(config.experts_dir.as_ref(), Some(pattern), None); @@ -137,15 +173,24 @@ pub async fn handle_search_experts(config: &Config, args: &Value) -> Result Result { let filter = args.get("filter").and_then(|v| v.as_str()); - let include_builtin = args.get("include_builtin").and_then(|v| v.as_bool()).unwrap_or(false); + let include_builtin = args + .get("include_builtin") + .and_then(|v| v.as_bool()) + .unwrap_or(false); let mut indicators = scan_mql_dir(config.indicators_dir.as_ref(), filter, Some("custom")); if include_builtin { let filter_lower = filter.map(|f| f.to_lowercase()); for &name in BUILTIN_INDICATORS { - if filter_lower.as_ref().map(|f| name.to_lowercase().contains(f.as_str())).unwrap_or(true) { - indicators.push(json!({ "name": name, "compiled": true, "type": "builtin", "path": null })); + if filter_lower + .as_ref() + .map(|f| name.to_lowercase().contains(f.as_str())) + .unwrap_or(true) + { + indicators.push( + json!({ "name": name, "compiled": true, "type": "builtin", "path": null }), + ); } } indicators.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); @@ -171,18 +216,28 @@ pub async fn handle_list_scripts(config: &Config, args: &Value) -> Result } pub async fn handle_search_indicators(config: &Config, args: &Value) -> Result { - let pattern = args.get("pattern") + let pattern = args + .get("pattern") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("pattern is required"))?; - let include_builtin = args.get("include_builtin").and_then(|v| v.as_bool()).unwrap_or(false); + let include_builtin = args + .get("include_builtin") + .and_then(|v| v.as_bool()) + .unwrap_or(false); - let mut matches = scan_mql_dir(config.indicators_dir.as_ref(), Some(pattern), Some("custom")); + let mut matches = scan_mql_dir( + config.indicators_dir.as_ref(), + Some(pattern), + Some("custom"), + ); if include_builtin { let pattern_lower = pattern.to_lowercase(); for &name in BUILTIN_INDICATORS { if name.to_lowercase().contains(&pattern_lower) { - matches.push(json!({ "name": name, "path": null, "type": "builtin", "compiled": true })); + matches.push( + json!({ "name": name, "path": null, "type": "builtin", "compiled": true }), + ); } } matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str())); @@ -197,7 +252,8 @@ pub async fn handle_search_indicators(config: &Config, args: &Value) -> Result Result { - let pattern = args.get("pattern") + let pattern = args + .get("pattern") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("pattern is required"))?; let matches = scan_mql_dir(config.scripts_dir.as_ref(), Some(pattern), None); @@ -217,7 +273,11 @@ pub async fn handle_compile_ea(config: &Config, args: &Value) -> Result { } else if let Some(name) = args.get("expert").and_then(|v| v.as_str()) { let mut candidates = vec![PathBuf::from(name).with_extension("mq5")]; if let Some(experts_dir) = &config.experts_dir { - candidates.push(PathBuf::from(experts_dir).join(name).join(format!("{}.mq5", name))); + candidates.push( + PathBuf::from(experts_dir) + .join(name) + .join(format!("{}.mq5", name)), + ); candidates.push(PathBuf::from(experts_dir).join(format!("{}.mq5", name))); } match candidates.into_iter().find(|p| p.exists()) { @@ -230,7 +290,9 @@ pub async fn handle_compile_ea(config: &Config, args: &Value) -> Result { )), } } else { - return Err(anyhow::anyhow!("Either 'expert' or 'expert_path' is required")); + return Err(anyhow::anyhow!( + "Either 'expert' or 'expert_path' is required" + )); }; let compiler = MqlCompiler::new(config.clone()); @@ -249,7 +311,10 @@ pub async fn handle_compile_ea(config: &Config, args: &Value) -> Result { "isError": !result.success })), Err(e) => Ok(err_response( - serde_json::to_string(&json!({ "success": false, "error": format!("Compilation failed: {}", e) })).unwrap_or_default() + serde_json::to_string( + &json!({ "success": false, "error": format!("Compilation failed: {}", e) }), + ) + .unwrap_or_default(), )), } } diff --git a/src/tools/handlers/mod.rs b/src/tools/handlers/mod.rs index 30ef834..0eaf7bd 100644 --- a/src/tools/handlers/mod.rs +++ b/src/tools/handlers/mod.rs @@ -1,20 +1,20 @@ +use crate::models::Config; use anyhow::Result; use chrono::Datelike; use serde_json::{json, Value}; use std::path::Path; -use std::sync::{Arc, OnceLock}; use std::sync::atomic::{AtomicBool, Ordering}; -use crate::models::Config; +use std::sync::{Arc, OnceLock}; type NotificationCallback = Arc; -mod system; -mod experts; -mod backtest; -mod optimization; mod analysis; -mod setfiles; +mod backtest; +mod experts; +mod optimization; mod reports; +mod setfiles; +mod system; mod utility; /// Cached result of the background update check. @@ -32,11 +32,17 @@ pub struct ToolHandler { impl ToolHandler { pub fn new(config: Config) -> Self { - Self { config, notification_callback: None } + Self { + config, + notification_callback: None, + } } pub fn with_notification_callback(config: Config, callback: NotificationCallback) -> Self { - Self { config, notification_callback: Some(callback) } + Self { + config, + notification_callback: Some(callback), + } } pub async fn handle(&self, name: &str, args: &Value) -> Result { @@ -50,13 +56,13 @@ impl ToolHandler { match name { // System handlers - "verify_setup" => system::handle_verify_setup(&self.config).await, - "list_symbols" => system::handle_list_symbols(&self.config).await, - "healthcheck" => system::handle_healthcheck(&self.config, args).await, + "verify_setup" => system::handle_verify_setup(&self.config).await, + "list_symbols" => system::handle_list_symbols(&self.config).await, + "healthcheck" => system::handle_healthcheck(&self.config, args).await, "get_active_account" => system::handle_get_active_account(&self.config).await, - "check_update" => system::handle_check_update(&self.config).await, - "update" => system::handle_update(&self.config).await, - + "check_update" => system::handle_check_update(&self.config).await, + "update" => system::handle_update(&self.config).await, + // Expert/Indicator/Script handlers "list_experts" => experts::handle_list_experts(&self.config, args).await, "list_indicators" => experts::handle_list_indicators(&self.config, args).await, @@ -65,49 +71,83 @@ impl ToolHandler { "search_experts" => experts::handle_search_experts(&self.config, args).await, "search_indicators" => experts::handle_search_indicators(&self.config, args).await, "search_scripts" => experts::handle_search_scripts(&self.config, args).await, - "copy_indicator_to_project" => experts::handle_copy_indicator_to_project(&self.config, args).await, - "copy_script_to_project" => experts::handle_copy_script_to_project(&self.config, args).await, - + "copy_indicator_to_project" => { + experts::handle_copy_indicator_to_project(&self.config, args).await + } + "copy_script_to_project" => { + experts::handle_copy_script_to_project(&self.config, args).await + } + // Backtest handlers - Granular pipeline options - "run_backtest" => backtest::handle_run_backtest(&self.config, args).await, // Full: compile + clean + backtest + extract + analyze + "run_backtest" => backtest::handle_run_backtest(&self.config, args).await, // Full: compile + clean + backtest + extract + analyze "run_backtest_quick" => backtest::handle_run_backtest_quick(self, args).await, // Quick: skip compile, fire-and-forget launch - "run_backtest_only" => backtest::handle_run_backtest_only(self, args).await, // Minimal: skip compile+analyze, fire-and-forget launch - "launch_backtest" => backtest::handle_launch_backtest(self, args).await, // Fire-and-forget mode + "run_backtest_only" => backtest::handle_run_backtest_only(self, args).await, // Minimal: skip compile+analyze, fire-and-forget launch + "launch_backtest" => backtest::handle_launch_backtest(self, args).await, // Fire-and-forget mode "get_backtest_status" => backtest::handle_get_backtest_status(&self.config, args).await, - "get_tester_log" => backtest::handle_get_tester_log(&self.config, args).await, - "run_rolling_backtest" => backtest::handle_run_rolling_backtest(&self.config, args).await, + "get_tester_log" => backtest::handle_get_tester_log(&self.config, args).await, + "run_rolling_backtest" => { + backtest::handle_run_rolling_backtest(&self.config, args).await + } "cache_status" => backtest::handle_cache_status(&self.config).await, "clean_cache" => backtest::handle_clean_cache(&self.config, args).await, - + // Optimization handlers "run_optimization" => optimization::handle_run_optimization(&self.config, args).await, - "get_optimization_status" => optimization::handle_get_optimization_status(&self.config, args).await, - "get_optimization_results" => optimization::handle_get_optimization_results(&self.config, args).await, + "get_optimization_status" => { + optimization::handle_get_optimization_status(&self.config, args).await + } + "get_optimization_results" => { + optimization::handle_get_optimization_results(&self.config, args).await + } "list_jobs" => optimization::handle_list_jobs(&self.config).await, - + // Analysis handlers "analyze_report" => analysis::handle_analyze_report(&self.config, args).await, "analyze_monthly_pnl" => analysis::handle_analyze_monthly_pnl(&self.config, args).await, - "analyze_drawdown_events" => analysis::handle_analyze_drawdown_events(&self.config, args).await, + "analyze_drawdown_events" => { + analysis::handle_analyze_drawdown_events(&self.config, args).await + } "analyze_top_losses" => analysis::handle_analyze_top_losses(&self.config, args).await, - "analyze_loss_sequences" => analysis::handle_analyze_loss_sequences(&self.config, args).await, - "analyze_position_pairs" => analysis::handle_analyze_position_pairs(&self.config, args).await, - "analyze_direction_bias" => analysis::handle_analyze_direction_bias(&self.config, args).await, + "analyze_loss_sequences" => { + analysis::handle_analyze_loss_sequences(&self.config, args).await + } + "analyze_position_pairs" => { + analysis::handle_analyze_position_pairs(&self.config, args).await + } + "analyze_direction_bias" => { + analysis::handle_analyze_direction_bias(&self.config, args).await + } "analyze_streaks" => analysis::handle_analyze_streaks(&self.config, args).await, - "analyze_concurrent_peak" => analysis::handle_analyze_concurrent_peak(&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, + "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, "write_set_file" => setfiles::handle_write_set_file(args).await, @@ -117,7 +157,7 @@ impl ToolHandler { "set_from_optimization" => setfiles::handle_set_from_optimization(args).await, "describe_sweep" => setfiles::handle_describe_sweep(args).await, "list_set_files" => setfiles::handle_list_set_files(&self.config).await, - + // Report handlers "get_latest_report" => reports::handle_get_latest_report(&self.config, args).await, "list_reports" => reports::handle_list_reports(args).await, @@ -133,15 +173,21 @@ impl ToolHandler { "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_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, "export_deals_csv" => reports::handle_export_deals_csv(&self.config, args).await, - + // Utility handlers - "check_symbol_data_status" => utility::handle_check_symbol_data_status(&self.config, args).await, - "get_backtest_history" => utility::handle_get_backtest_history(&self.config, args).await, + "check_symbol_data_status" => { + utility::handle_check_symbol_data_status(&self.config, args).await + } + "get_backtest_history" => { + utility::handle_get_backtest_history(&self.config, args).await + } "compare_backtests" => utility::handle_compare_backtests(&self.config, args).await, "init_project" => utility::handle_init_project(&self.config, args).await, "validate_ea_syntax" => utility::handle_validate_ea_syntax(&self.config, args).await, @@ -154,10 +200,16 @@ impl ToolHandler { "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, + "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, + "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) }], @@ -186,10 +238,10 @@ pub(crate) fn past_complete_month() -> (String, String) { let now = chrono::Utc::now(); let today = chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1) .unwrap_or_else(|| chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap()); - let last_of_prev = today.pred_opt() - .unwrap_or(today); - let first_of_prev = chrono::NaiveDate::from_ymd_opt(last_of_prev.year(), last_of_prev.month(), 1) - .unwrap_or(last_of_prev); + let last_of_prev = today.pred_opt().unwrap_or(today); + let first_of_prev = + chrono::NaiveDate::from_ymd_opt(last_of_prev.year(), last_of_prev.month(), 1) + .unwrap_or(last_of_prev); ( first_of_prev.format("%Y.%m.%d").to_string(), last_of_prev.format("%Y.%m.%d").to_string(), diff --git a/src/tools/handlers/optimization.rs b/src/tools/handlers/optimization.rs index 7107535..ff8abff 100644 --- a/src/tools/handlers/optimization.rs +++ b/src/tools/handlers/optimization.rs @@ -1,36 +1,54 @@ +use crate::models::Config; +use crate::optimization::{OptimizationParams, OptimizationParser, OptimizationRunner}; use anyhow::Result; use serde_json::{json, Value}; use std::fs; -use crate::models::Config; -use crate::optimization::{OptimizationParams, OptimizationParser, OptimizationRunner}; pub async fn handle_run_optimization(config: &Config, args: &Value) -> Result { - let expert = args.get("expert") + let expert = args + .get("expert") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("expert is required"))?; - let set_file = args.get("set_file") + let set_file = args + .get("set_file") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("set_file is required"))?; - let from_date = args.get("from_date") + let from_date = args + .get("from_date") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("from_date is required"))?; - let to_date = args.get("to_date") + let to_date = args + .get("to_date") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("to_date is required"))?; let params = OptimizationParams { expert: expert.to_string(), set_file: set_file.to_string(), - symbol: args.get("symbol").and_then(|v| v.as_str()).unwrap_or("XAUUSD").to_string(), + symbol: args + .get("symbol") + .and_then(|v| v.as_str()) + .unwrap_or("XAUUSD") + .to_string(), from_date: from_date.to_string(), to_date: to_date.to_string(), - deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32, + deposit: args + .get("deposit") + .and_then(|v| v.as_u64()) + .unwrap_or(10000) as u32, leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32, - currency: args.get("currency").and_then(|v| v.as_str()).unwrap_or("USD").to_string(), - max_passes: args.get("max_passes").and_then(|v| v.as_u64()).map(|v| v as u32), + currency: args + .get("currency") + .and_then(|v| v.as_str()) + .unwrap_or("USD") + .to_string(), + max_passes: args + .get("max_passes") + .and_then(|v| v.as_u64()) + .map(|v| v as u32), }; let runner = OptimizationRunner::new(config.clone()); @@ -50,7 +68,8 @@ pub async fn handle_run_optimization(config: &Config, args: &Value) -> Result Result { - let job_id = args.get("job_id") + let job_id = args + .get("job_id") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("job_id is required"))?; @@ -64,8 +83,14 @@ pub async fn handle_get_optimization_status(config: &Config, args: &Value) -> Re if let Ok(meta_str) = fs::read_to_string(&meta_path) { if let Ok(mut meta) = serde_json::from_str::(&meta_str) { if let Some(obj) = meta.as_object_mut() { - obj.insert("status".into(), serde_json::Value::String("completed".into())); - obj.insert("completed_at".into(), serde_json::Value::String(chrono::Utc::now().to_rfc3339())); + obj.insert( + "status".into(), + serde_json::Value::String("completed".into()), + ); + obj.insert( + "completed_at".into(), + serde_json::Value::String(chrono::Utc::now().to_rfc3339()), + ); if let Some(top) = status.get("top_10") { obj.insert("top_10".into(), top.clone()); } @@ -75,7 +100,10 @@ pub async fn handle_get_optimization_status(config: &Config, args: &Value) -> Re if let Some(total) = status.get("total_passes") { obj.insert("total_passes".into(), total.clone()); } - let _ = fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap_or_default()); + let _ = fs::write( + &meta_path, + serde_json::to_string_pretty(&meta).unwrap_or_default(), + ); } } } @@ -88,14 +116,12 @@ pub async fn handle_get_optimization_status(config: &Config, args: &Value) -> Re } pub async fn handle_get_optimization_results(_config: &Config, args: &Value) -> Result { - let job_id = args.get("job_id") - .and_then(|v| v.as_str()); + let job_id = args.get("job_id").and_then(|v| v.as_str()); - let file = args.get("report_file") - .and_then(|v| v.as_str()); + let file = args.get("report_file").and_then(|v| v.as_str()); let parser = OptimizationParser::new(); - + let passes = if let Some(jid) = job_id { parser.parse_job(jid)? } else if let Some(f) = file { @@ -104,7 +130,10 @@ pub async fn handle_get_optimization_results(_config: &Config, args: &Value) -> return Err(anyhow::anyhow!("Either job_id or file is required")); }; - let sort_by = args.get("sort").and_then(|v| v.as_str()).unwrap_or("profit"); + let sort_by = args + .get("sort") + .and_then(|v| v.as_str()) + .unwrap_or("profit"); let top_n = args.get("top_n").and_then(|v| v.as_u64()).unwrap_or(30) as usize; let best = parser.find_best_pass(&passes, sort_by); diff --git a/src/tools/handlers/reports.rs b/src/tools/handlers/reports.rs index ed479d1..bc71dc6 100644 --- a/src/tools/handlers/reports.rs +++ b/src/tools/handlers/reports.rs @@ -1,14 +1,17 @@ -use anyhow::Result; -use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; -use serde_json::{json, Value}; -use std::fs; -use std::path::Path; use crate::analytics::ReportExtractor; use crate::models::Config; use crate::storage::{ReportDb, ReportFilters}; +use anyhow::Result; +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use serde_json::{json, Value}; +use std::fs; +use std::path::Path; pub async fn handle_get_latest_report(_config: &Config, args: &Value) -> Result { - let include_chart = args.get("include_chart").and_then(|v| v.as_bool()).unwrap_or(true); + let include_chart = args + .get("include_chart") + .and_then(|v| v.as_bool()) + .unwrap_or(true); let db = ReportDb::new(&Config::db_path()); if let Err(e) = db.init() { @@ -63,14 +66,17 @@ pub async fn handle_get_latest_report(_config: &Config, args: &Value) -> Result< response["report"]["equity_chart_format"] = json!("png"); } Err(e) => { - response["report"]["equity_chart_error"] = json!(format!("Failed to read chart: {}", 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"); + response["report"]["equity_chart_error"] = + json!("equity.png not found in charts_dir"); } } else { - response["report"]["equity_chart_error"] = json!("No charts_dir available for this report"); + response["report"]["equity_chart_error"] = + json!("No charts_dir available for this report"); } } @@ -79,15 +85,13 @@ pub async fn handle_get_latest_report(_config: &Config, args: &Value) -> Result< "isError": false })) } - None => { - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": false, - "error": "No reports found in database" - }).to_string() }], - "isError": true - })) - } + None => Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": false, + "error": "No reports found in database" + }).to_string() }], + "isError": true + })), } } @@ -108,26 +112,28 @@ pub async fn handle_list_reports(args: &Value) -> Result { let reports: Vec = entries .iter() - .map(|e| json!({ - "id": e.id, - "expert": e.expert, - "symbol": e.symbol, - "timeframe": e.timeframe, - "from_date": e.from_date, - "to_date": e.to_date, - "created_at": e.created_at, - "net_profit": e.net_profit, - "profit_factor": e.profit_factor, - "max_dd_pct": e.max_dd_pct, - "total_trades": e.total_trades, - "win_rate_pct": e.win_rate_pct, - "set_file": e.set_file_original, - "charts_dir": e.charts_dir, - "report_dir": e.report_dir, - "verdict": e.verdict, - "tags": e.tags, - "notes": e.notes, - })) + .map(|e| { + json!({ + "id": e.id, + "expert": e.expert, + "symbol": e.symbol, + "timeframe": e.timeframe, + "from_date": e.from_date, + "to_date": e.to_date, + "created_at": e.created_at, + "net_profit": e.net_profit, + "profit_factor": e.profit_factor, + "max_dd_pct": e.max_dd_pct, + "total_trades": e.total_trades, + "win_rate_pct": e.win_rate_pct, + "set_file": e.set_file_original, + "charts_dir": e.charts_dir, + "report_dir": e.report_dir, + "verdict": e.verdict, + "tags": e.tags, + "notes": e.notes, + }) + }) .collect(); Ok(json!({ @@ -147,40 +153,57 @@ pub async fn handle_search_reports(args: &Value) -> Result { db.init()?; let filters = ReportFilters { - expert: args.get("expert").and_then(|v| v.as_str()).map(|s| s.to_string()), - symbol: args.get("symbol").and_then(|v| v.as_str()).map(|s| s.to_string()), - timeframe: args.get("timeframe").and_then(|v| v.as_str()).map(|s| s.to_string()), - created_after: args.get("after").and_then(|v| v.as_str()).map(|s| s.to_string()), + expert: args + .get("expert") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + symbol: args + .get("symbol") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + timeframe: args + .get("timeframe") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + created_after: args + .get("after") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), min_profit: args.get("min_profit").and_then(|v| v.as_f64()), max_dd: args.get("max_dd").and_then(|v| v.as_f64()), - verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()), + verdict: args + .get("verdict") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), }; let entries = db.list(limit, &filters)?; let reports: Vec = entries .iter() - .map(|e| json!({ - "id": e.id, - "expert": e.expert, - "symbol": e.symbol, - "timeframe": e.timeframe, - "from_date": e.from_date, - "to_date": e.to_date, - "created_at": e.created_at, - "net_profit": e.net_profit, - "profit_factor": e.profit_factor, - "max_dd_pct": e.max_dd_pct, - "total_trades": e.total_trades, - "win_rate_pct": e.win_rate_pct, - "set_file": e.set_file_original, - "set_snapshot": e.set_snapshot_path, - "charts_dir": e.charts_dir, - "report_dir": e.report_dir, - "verdict": e.verdict, - "tags": e.tags, - "notes": e.notes, - })) + .map(|e| { + json!({ + "id": e.id, + "expert": e.expert, + "symbol": e.symbol, + "timeframe": e.timeframe, + "from_date": e.from_date, + "to_date": e.to_date, + "created_at": e.created_at, + "net_profit": e.net_profit, + "profit_factor": e.profit_factor, + "max_dd_pct": e.max_dd_pct, + "total_trades": e.total_trades, + "win_rate_pct": e.win_rate_pct, + "set_file": e.set_file_original, + "set_snapshot": e.set_snapshot_path, + "charts_dir": e.charts_dir, + "report_dir": e.report_dir, + "verdict": e.verdict, + "tags": e.tags, + "notes": e.notes, + }) + }) .collect(); Ok(json!({ @@ -194,7 +217,10 @@ pub async fn handle_search_reports(args: &Value) -> Result { pub async fn handle_prune_reports(_config: &Config, args: &Value) -> Result { let keep_last = args.get("keep_last").and_then(|v| v.as_u64()).unwrap_or(20) as usize; - let dry_run = args.get("dry_run").and_then(|v| v.as_bool()).unwrap_or(false); + let dry_run = args + .get("dry_run") + .and_then(|v| v.as_bool()) + .unwrap_or(false); let db = ReportDb::new(&Config::db_path()); db.init()?; @@ -240,9 +266,13 @@ pub async fn handle_tail_log(_config: &Config, args: &Value) -> Result { let jobs_dir = std::env::temp_dir().join(".mt5mcp_jobs"); let meta_path = jobs_dir.join(format!("{}.json", jid)); let meta: Value = serde_json::from_str(&fs::read_to_string(meta_path)?)?; - meta.get("log_file").and_then(|v| v.as_str()).map(|s| s.to_string()) + meta.get("log_file") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) } else { - args.get("file").and_then(|v| v.as_str()).map(|s| s.to_string()) + args.get("file") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) }; let log_path = log_path.ok_or_else(|| anyhow::anyhow!("Could not determine log file"))?; @@ -259,24 +289,34 @@ pub async fn handle_tail_log(_config: &Config, args: &Value) -> Result { } pub async fn handle_archive_report(_config: &Config, args: &Value) -> Result { - let report_dir = args.get("report_dir") + let report_dir = args + .get("report_dir") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; - let delete_after = args.get("delete_after").and_then(|v| v.as_bool()).unwrap_or(false); + let delete_after = args + .get("delete_after") + .and_then(|v| v.as_bool()) + .unwrap_or(false); let history_dir = Path::new(".mt5mcp_history"); fs::create_dir_all(history_dir)?; - let report_name = Path::new(report_dir).file_name() + let report_name = Path::new(report_dir) + .file_name() .and_then(|s| s.to_str()) .unwrap_or("unknown"); let archive_path = history_dir.join(format!("{}.tar.gz", report_name)); let status = std::process::Command::new("tar") - .args(["-czf", &archive_path.to_string_lossy(), "-C", - Path::new(report_dir).parent().unwrap().to_str().unwrap(), report_name]) + .args([ + "-czf", + &archive_path.to_string_lossy(), + "-C", + Path::new(report_dir).parent().unwrap().to_str().unwrap(), + report_name, + ]) .status()?; if delete_after && status.success() { @@ -305,19 +345,33 @@ pub async fn handle_archive_all_reports(config: &Config, args: &Value) -> Result if let Ok(entries) = fs::read_dir(&reports_dir) { let mut entries: Vec<_> = entries.flatten().collect(); entries.sort_by(|a, b| { - b.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH) - .cmp(&a.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH)) + b.metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::UNIX_EPOCH) + .cmp( + &a.metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::UNIX_EPOCH), + ) }); for entry in entries.into_iter().skip(keep_last) { let path = entry.path(); if path.is_dir() && !path.to_string_lossy().ends_with("_opt") { - let report_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("unknown"); + let report_name = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("unknown"); let archive_path = history_dir.join(format!("{}.tar.gz", report_name)); let _ = std::process::Command::new("tar") - .args(["-czf", &archive_path.to_string_lossy(), "-C", - path.parent().unwrap().to_str().unwrap(), report_name]) + .args([ + "-czf", + &archive_path.to_string_lossy(), + "-C", + path.parent().unwrap().to_str().unwrap(), + report_name, + ]) .status(); let _ = fs::remove_dir_all(&path); @@ -337,14 +391,15 @@ pub async fn handle_archive_all_reports(config: &Config, args: &Value) -> Result } pub async fn handle_promote_to_baseline(_config: &Config, args: &Value) -> Result { - let report_dir = args.get("report_dir") + let report_dir = args + .get("report_dir") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; let metrics_path = Path::new(report_dir).join("metrics.json"); let baseline_path = Path::new("config/baseline.json"); - fs::copy(&metrics_path, &baseline_path)?; + fs::copy(&metrics_path, baseline_path)?; Ok(json!({ "content": [{ "type": "text", "text": json!({ @@ -363,9 +418,18 @@ pub async fn handle_get_history(args: &Value) -> Result { db.init()?; let filters = ReportFilters { - expert: args.get("ea").and_then(|v| v.as_str()).map(|s| s.to_string()), - symbol: args.get("symbol").and_then(|v| v.as_str()).map(|s| s.to_string()), - verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()), + expert: args + .get("ea") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + symbol: args + .get("symbol") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + verdict: args + .get("verdict") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), ..Default::default() }; @@ -374,25 +438,27 @@ pub async fn handle_get_history(args: &Value) -> Result { let history: Vec = entries .iter() - .map(|e| json!({ - "id": e.id, - "expert": e.expert, - "symbol": e.symbol, - "timeframe": e.timeframe, - "from_date": e.from_date, - "to_date": e.to_date, - "created_at": e.created_at, - "net_profit": e.net_profit, - "profit_factor": e.profit_factor, - "max_dd_pct": e.max_dd_pct, - "total_trades": e.total_trades, - "set_file": e.set_file_original, - "set_snapshot": e.set_snapshot_path, - "charts_dir": e.charts_dir, - "verdict": e.verdict, - "tags": e.tags, - "notes": e.notes, - })) + .map(|e| { + json!({ + "id": e.id, + "expert": e.expert, + "symbol": e.symbol, + "timeframe": e.timeframe, + "from_date": e.from_date, + "to_date": e.to_date, + "created_at": e.created_at, + "net_profit": e.net_profit, + "profit_factor": e.profit_factor, + "max_dd_pct": e.max_dd_pct, + "total_trades": e.total_trades, + "set_file": e.set_file_original, + "set_snapshot": e.set_snapshot_path, + "charts_dir": e.charts_dir, + "verdict": e.verdict, + "tags": e.tags, + "notes": e.notes, + }) + }) .collect(); Ok(json!({ @@ -414,10 +480,11 @@ pub async fn handle_annotate_history(args: &Value) -> Result { let notes = args.get("notes").and_then(|v| v.as_str()); let verdict = args.get("verdict").and_then(|v| v.as_str()); - let tags: Option> = args - .get("tags") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()); + let tags: Option> = args.get("tags").and_then(|v| v.as_array()).map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }); let db = ReportDb::new(&Config::db_path()); db.init()?; @@ -436,10 +503,14 @@ pub async fn handle_annotate_history(args: &Value) -> Result { } pub async fn handle_get_report_by_id(_config: &Config, args: &Value) -> Result { - let id = args.get("id") + 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 include_chart = args + .get("include_chart") + .and_then(|v| v.as_bool()) + .unwrap_or(true); let db = ReportDb::new(&Config::db_path()); db.init()?; @@ -488,11 +559,13 @@ pub async fn handle_get_report_by_id(_config: &Config, args: &Value) -> Result { - response["report"]["equity_chart_error"] = json!(format!("Failed to read chart: {}", 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"); + response["report"]["equity_chart_error"] = + json!("equity.png not found in charts_dir"); } } } @@ -502,15 +575,13 @@ pub async fn handle_get_report_by_id(_config: &Config, args: &Value) -> Result { - Ok(json!({ - "content": [{ "type": "text", "text": json!({ - "success": false, - "error": format!("Report with id '{}' not found", id) - }).to_string() }], - "isError": true - })) - } + None => Ok(json!({ + "content": [{ "type": "text", "text": json!({ + "success": false, + "error": format!("Report with id '{}' not found", id) + }).to_string() }], + "isError": true + })), } } @@ -519,10 +590,22 @@ pub async fn handle_get_reports_summary(args: &Value) -> Result { 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()), + 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() }; @@ -555,16 +638,31 @@ pub async fn handle_get_best_reports(args: &Value) -> Result { let db = ReportDb::new(&Config::db_path()); db.init()?; - let sort_by = args.get("sort_by").and_then(|v| v.as_str()).unwrap_or("profit_factor"); + let 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()), + 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() }; @@ -572,24 +670,26 @@ pub async fn handle_get_best_reports(args: &Value) -> Result { let reports: Vec = entries .iter() - .map(|e| json!({ - "id": e.id, - "expert": e.expert, - "symbol": e.symbol, - "timeframe": e.timeframe, - "from_date": e.from_date, - "to_date": e.to_date, - "created_at": e.created_at, - "net_profit": e.net_profit, - "profit_factor": e.profit_factor, - "max_dd_pct": e.max_dd_pct, - "sharpe_ratio": e.sharpe_ratio, - "total_trades": e.total_trades, - "win_rate_pct": e.win_rate_pct, - "set_file": e.set_file_original, - "verdict": e.verdict, - "tags": e.tags, - })) + .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!({ @@ -607,7 +707,11 @@ pub async fn handle_search_reports_by_tags(args: &Value) -> Result { let tags: Vec = args .get("tags") .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect()) + .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; @@ -619,24 +723,26 @@ pub async fn handle_search_reports_by_tags(args: &Value) -> Result { let reports: Vec = entries .iter() - .map(|e| json!({ - "id": e.id, - "expert": e.expert, - "symbol": e.symbol, - "timeframe": e.timeframe, - "from_date": e.from_date, - "to_date": e.to_date, - "created_at": e.created_at, - "net_profit": e.net_profit, - "profit_factor": e.profit_factor, - "max_dd_pct": e.max_dd_pct, - "total_trades": e.total_trades, - "win_rate_pct": e.win_rate_pct, - "set_file": e.set_file_original, - "verdict": e.verdict, - "tags": e.tags, - "notes": e.notes, - })) + .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!({ @@ -663,23 +769,25 @@ pub async fn handle_search_reports_by_date_range(args: &Value) -> Result let reports: Vec = entries .iter() - .map(|e| json!({ - "id": e.id, - "expert": e.expert, - "symbol": e.symbol, - "timeframe": e.timeframe, - "from_date": e.from_date, - "to_date": e.to_date, - "created_at": e.created_at, - "net_profit": e.net_profit, - "profit_factor": e.profit_factor, - "max_dd_pct": e.max_dd_pct, - "total_trades": e.total_trades, - "win_rate_pct": e.win_rate_pct, - "set_file": e.set_file_original, - "verdict": e.verdict, - "tags": e.tags, - })) + .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!({ @@ -696,7 +804,8 @@ pub async fn handle_search_reports_by_date_range(args: &Value) -> Result } pub async fn handle_search_reports_by_notes(args: &Value) -> Result { - let query = args.get("query") + 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; @@ -708,24 +817,26 @@ pub async fn handle_search_reports_by_notes(args: &Value) -> Result { let reports: Vec = entries .iter() - .map(|e| json!({ - "id": e.id, - "expert": e.expert, - "symbol": e.symbol, - "timeframe": e.timeframe, - "from_date": e.from_date, - "to_date": e.to_date, - "created_at": e.created_at, - "net_profit": e.net_profit, - "profit_factor": e.profit_factor, - "max_dd_pct": e.max_dd_pct, - "total_trades": e.total_trades, - "win_rate_pct": e.win_rate_pct, - "set_file": e.set_file_original, - "verdict": e.verdict, - "notes": e.notes, - "tags": e.tags, - })) + .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!({ @@ -739,7 +850,8 @@ pub async fn handle_search_reports_by_notes(args: &Value) -> Result { } pub async fn handle_get_reports_by_set_file(args: &Value) -> Result { - let set_file = args.get("set_file") + 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; @@ -751,24 +863,26 @@ pub async fn handle_get_reports_by_set_file(args: &Value) -> Result { let reports: Vec = entries .iter() - .map(|e| json!({ - "id": e.id, - "expert": e.expert, - "symbol": e.symbol, - "timeframe": e.timeframe, - "from_date": e.from_date, - "to_date": e.to_date, - "created_at": e.created_at, - "net_profit": e.net_profit, - "profit_factor": e.profit_factor, - "max_dd_pct": e.max_dd_pct, - "total_trades": e.total_trades, - "win_rate_pct": e.win_rate_pct, - "set_file_original": e.set_file_original, - "set_snapshot_path": e.set_snapshot_path, - "verdict": e.verdict, - "tags": e.tags, - })) + .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!({ @@ -786,55 +900,86 @@ pub async fn handle_get_comparable_reports(args: &Value) -> Result { 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())) + 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 + })) + } } - 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()) - }; + } 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 exclude_opt = if exclude_id.is_empty() { + None + } else { + Some(exclude_id.as_str()) + }; let entries = db.get_comparable(&expert, &symbol, &timeframe, exclude_opt, limit)?; let reports: Vec = entries .iter() - .map(|e| json!({ - "id": e.id, - "expert": e.expert, - "symbol": e.symbol, - "timeframe": e.timeframe, - "from_date": e.from_date, - "to_date": e.to_date, - "created_at": e.created_at, - "net_profit": e.net_profit, - "profit_factor": e.profit_factor, - "max_dd_pct": e.max_dd_pct, - "total_trades": e.total_trades, - "win_rate_pct": e.win_rate_pct, - "set_file": e.set_file_original, - "verdict": e.verdict, - "tags": e.tags, - })) + .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!({ @@ -853,7 +998,9 @@ pub async fn handle_get_comparable_reports(args: &Value) -> Result { pub async fn handle_export_deals_csv(_config: &Config, args: &Value) -> Result { let db = ReportDb::new(&Config::db_path()); if let Err(e) = db.init() { - return Ok(json!({ "content": [{ "type": "text", "text": format!("DB error: {}", e) }], "isError": true })); + return Ok( + json!({ "content": [{ "type": "text", "text": format!("DB error: {}", e) }], "isError": true }), + ); } let report_id_opt = args.get("report_id").and_then(|v| v.as_str()); @@ -864,12 +1011,18 @@ pub async fn handle_export_deals_csv(_config: &Config, args: &Value) -> Result e, - None => return Ok(json!({ "content": [{ "type": "text", "text": "No report found" }], "isError": true })), + None => { + return Ok( + json!({ "content": [{ "type": "text", "text": "No report found" }], "isError": true }), + ) + } }; let deals = db.get_deals(&entry.id)?; if deals.is_empty() { - return Ok(json!({ "content": [{ "type": "text", "text": format!("No deals stored for report {}", entry.id) }], "isError": false })); + return Ok( + json!({ "content": [{ "type": "text", "text": format!("No deals stored for report {}", entry.id) }], "isError": false }), + ); } let output_path = match args.get("output_path").and_then(|v| v.as_str()) { @@ -878,7 +1031,8 @@ pub async fn handle_export_deals_csv(_config: &Config, args: &Value) -> Result Result { @@ -14,13 +14,13 @@ fn read_file_as_utf8(path: &str) -> Result { String::from_utf16(&utf16_data) .map_err(|e| anyhow::anyhow!("Failed to decode UTF-16LE: {}", e)) } else { - String::from_utf8(bytes) - .map_err(|e| anyhow::anyhow!("Failed to decode as UTF-8: {}", e)) + String::from_utf8(bytes).map_err(|e| anyhow::anyhow!("Failed to decode as UTF-8: {}", e)) } } pub async fn handle_read_set_file(args: &Value) -> Result { - let path = args.get("path") + let path = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("path is required"))?; @@ -31,20 +31,26 @@ pub async fn handle_read_set_file(args: &Value) -> Result { if let Some((key, value)) = line.split_once('=') { let key = key.trim(); let value = value.trim(); - + if value.contains("||Y") { let parts: Vec<&str> = value.split("||").collect(); if parts.len() >= 5 { - params.insert(key.to_string(), json!({ - "value": parts[0], - "from": parts[1], - "step": parts[2], - "to": parts[3], - "optimize": true, - })); + params.insert( + key.to_string(), + json!({ + "value": parts[0], + "from": parts[1], + "step": parts[2], + "to": parts[3], + "optimize": true, + }), + ); } } else { - params.insert(key.to_string(), json!({ "value": value, "optimize": false })); + params.insert( + key.to_string(), + json!({ "value": value, "optimize": false }), + ); } } } @@ -59,29 +65,65 @@ pub async fn handle_read_set_file(args: &Value) -> Result { } pub async fn handle_write_set_file(args: &Value) -> Result { - let path = args.get("path") + let path = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("path is required"))?; - let params = args.get("parameters") + let params = args + .get("parameters") .and_then(|v| v.as_object()) .ok_or_else(|| anyhow::anyhow!("parameters object is required"))?; + /// A scalar JSON value (bool/number/string) rendered as a .set value. + fn scalar_to_value(v: &Value) -> String { + match v { + Value::Bool(b) => b.to_string(), + Value::String(s) => s.clone(), + other => other.to_string(), + } + } + let mut lines = Vec::new(); for (key, value) in params { if let Some(obj) = value.as_object() { - if obj.get("optimize").and_then(|v| v.as_bool()).unwrap_or(false) { + if obj + .get("optimize") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { let from_val = obj.get("from").and_then(|v| v.as_str()).unwrap_or("0"); let step = obj.get("step").and_then(|v| v.as_str()).unwrap_or("1"); let to_val = obj.get("to").and_then(|v| v.as_str()).unwrap_or("0"); - lines.push(format!("{}={}||{}||{}||{}||Y", key, obj.get("value").and_then(|v| v.as_str()).unwrap_or("0"), from_val, step, to_val)); + lines.push(format!( + "{}={}||{}||{}||{}||Y", + key, + obj.get("value").and_then(|v| v.as_str()).unwrap_or("0"), + from_val, + step, + to_val + )); } else { - lines.push(format!("{}={}", key, obj.get("value").and_then(|v| v.as_str()).unwrap_or("0"))); + lines.push(format!( + "{}={}", + key, + obj.get("value").and_then(|v| v.as_str()).unwrap_or("0") + )); } + } else { + // Plain scalar (bool/number/string), not the {"value":..,"optimize":..} + // form - accept it directly instead of silently dropping the parameter. + lines.push(format!("{}={}", key, scalar_to_value(value))); } } - fs::write(path, lines.join("\n"))?; + let mut utf16_content: Vec = vec![0xFEFF]; // BOM - matches the format MT5's tester actually writes/expects + utf16_content.extend(lines.join("\r\n").encode_utf16()); + let bytes: Vec = utf16_content + .iter() + .flat_map(|&c| [(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8]) + .collect(); + fs::write(path, bytes)?; Ok(json!({ "content": [{ "type": "text", "text": json!({ @@ -94,11 +136,13 @@ pub async fn handle_write_set_file(args: &Value) -> Result { } pub async fn handle_patch_set_file(args: &Value) -> Result { - let path = args.get("path") + let path = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("path is required"))?; - let patches = args.get("patches") + let patches = args + .get("patches") .and_then(|v| v.as_object()) .ok_or_else(|| anyhow::anyhow!("patches object is required"))?; @@ -112,7 +156,11 @@ pub async fn handle_patch_set_file(args: &Value) -> Result { } else if let Some(n) = value.as_f64() { n.to_string() } else if let Some(b) = value.as_bool() { - if b { "true".to_string() } else { "false".to_string() } + if b { + "true".to_string() + } else { + "false".to_string() + } } else { value.to_string() }; @@ -151,11 +199,13 @@ pub async fn handle_patch_set_file(args: &Value) -> Result { } pub async fn handle_clone_set_file(args: &Value) -> Result { - let source = args.get("source") + let source = args + .get("source") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("source is required"))?; - let destination = args.get("destination") + let destination = args + .get("destination") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("destination is required"))?; @@ -172,11 +222,13 @@ pub async fn handle_clone_set_file(args: &Value) -> Result { } pub async fn handle_diff_set_files(args: &Value) -> Result { - let file_a = args.get("file_a") + let file_a = args + .get("file_a") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("file_a is required"))?; - let file_b = args.get("file_b") + let file_b = args + .get("file_b") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("file_b is required"))?; @@ -207,11 +259,13 @@ pub async fn handle_diff_set_files(args: &Value) -> Result { } pub async fn handle_set_from_optimization(args: &Value) -> Result { - let path = args.get("path") + let path = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("path is required"))?; - let params = args.get("params") + let params = args + .get("params") .and_then(|v| v.as_object()) .ok_or_else(|| anyhow::anyhow!("params is required"))?; @@ -235,7 +289,8 @@ pub async fn handle_set_from_optimization(args: &Value) -> Result { } pub async fn handle_describe_sweep(args: &Value) -> Result { - let path = args.get("path") + let path = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("path is required"))?; @@ -246,15 +301,18 @@ pub async fn handle_describe_sweep(args: &Value) -> Result { if let Some((key, value)) = line.split_once('=') { let key = key.trim(); let value = value.trim(); - + if value.contains("||Y") { let parts: Vec<&str> = value.split("||").collect(); if parts.len() >= 5 && parts[4].trim().to_uppercase() == "Y" { - sweep_params.insert(key.to_string(), json!({ - "from": parts[1].trim(), - "to": parts[3].trim(), - "step": parts[2].trim(), - })); + sweep_params.insert( + key.to_string(), + json!({ + "from": parts[1].trim(), + "to": parts[3].trim(), + "step": parts[2].trim(), + }), + ); } } } @@ -278,7 +336,8 @@ pub async fn handle_list_set_files(config: &Config) -> Result { for entry in entries.flatten() { let path = entry.path(); if path.extension().map(|e| e == "set").unwrap_or(false) { - let name = path.file_stem() + let name = path + .file_stem() .and_then(|s| s.to_str()) .unwrap_or("unknown") .to_string(); diff --git a/src/tools/handlers/system.rs b/src/tools/handlers/system.rs index a91c61f..db3bf26 100644 --- a/src/tools/handlers/system.rs +++ b/src/tools/handlers/system.rs @@ -1,19 +1,23 @@ +use crate::models::Config; use anyhow::Result; use serde_json::{json, Value}; use std::path::Path; -use crate::models::Config; // ── Update helpers ──────────────────────────────────────────────────────────── fn platform_tag() -> &'static str { - #[cfg(all(target_os = "macos", target_arch = "aarch64"))] return "macos-aarch64"; - #[cfg(all(target_os = "macos", target_arch = "x86_64"))] return "macos-x86_64"; - #[cfg(all(target_os = "linux", target_arch = "x86_64"))] return "linux-x86_64"; + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + return "macos-aarch64"; + #[cfg(all(target_os = "macos", target_arch = "x86_64"))] + return "macos-x86_64"; + #[cfg(all(target_os = "linux", target_arch = "x86_64"))] + return "linux-x86_64"; #[cfg(not(any( all(target_os = "macos", target_arch = "aarch64"), all(target_os = "macos", target_arch = "x86_64"), all(target_os = "linux", target_arch = "x86_64"), - )))] return "unsupported"; + )))] + return "unsupported"; } fn semver_newer(latest: &str, current: &str) -> bool { @@ -32,16 +36,22 @@ fn semver_newer(latest: &str, current: &str) -> bool { pub(super) async fn fetch_latest_version() -> Option { let output = tokio::process::Command::new("curl") .args([ - "-sf", "--max-time", "5", - "-H", "Accept: application/vnd.github.v3+json", - "-H", "User-Agent: mt5-quant-updater", + "-sf", + "--max-time", + "5", + "-H", + "Accept: application/vnd.github.v3+json", + "-H", + "User-Agent: mt5-quant-updater", "https://api.github.com/repos/masdevid/mt5-quant/releases/latest", ]) .output() .await .ok()?; - if !output.status.success() { return None; } + if !output.status.success() { + return None; + } let body: Value = serde_json::from_slice(&output.stdout).ok()?; body.get("tag_name") @@ -96,9 +106,11 @@ pub async fn handle_update(_config: &Config) -> Result { Some(v) => v.to_string(), None => match fetch_latest_version().await { Some(v) => v, - None => return Ok(err_response( - r#"{"success":false,"error":"Could not determine latest version — check network"}"# - )), + None => { + return Ok(err_response( + r#"{"success":false,"error":"Could not determine latest version — check network"}"#, + )) + } }, }; @@ -112,7 +124,7 @@ pub async fn handle_update(_config: &Config) -> Result { let tag = platform_tag(); if tag == "unsupported" { return Ok(err_response( - r#"{"success":false,"error":"Auto-update not supported on this platform — build from source"}"# + r#"{"success":false,"error":"Auto-update not supported on this platform — build from source"}"#, )); } @@ -123,34 +135,48 @@ pub async fn handle_update(_config: &Config) -> Result { // Download tarball to a temp file let tmp_tar = tempfile::NamedTempFile::new()?; let dl = tokio::process::Command::new("curl") - .args(["-sfL", "--max-time", "120", - "-o", tmp_tar.path().to_str().unwrap_or_default(), - &url]) + .args([ + "-sfL", + "--max-time", + "120", + "-o", + tmp_tar.path().to_str().unwrap_or_default(), + &url, + ]) .status() .await?; if !dl.success() { return Ok(err_response(format!( - r#"{{"success":false,"error":"Download failed","url":"{}"}}"#, url + r#"{{"success":false,"error":"Download failed","url":"{}"}}"#, + url ))); } // Extract binary (tarball root dir is mcp-mt5-quant-{platform}/) let tmp_dir = tempfile::tempdir()?; let extract = tokio::process::Command::new("tar") - .args(["-xzf", tmp_tar.path().to_str().unwrap_or_default(), - "-C", tmp_dir.path().to_str().unwrap_or_default(), - "--strip-components=1"]) + .args([ + "-xzf", + tmp_tar.path().to_str().unwrap_or_default(), + "-C", + tmp_dir.path().to_str().unwrap_or_default(), + "--strip-components=1", + ]) .status() .await?; if !extract.success() { - return Ok(err_response(r#"{"success":false,"error":"Failed to extract archive"}"#)); + return Ok(err_response( + r#"{"success":false,"error":"Failed to extract archive"}"#, + )); } let new_bin = tmp_dir.path().join("mt5-quant"); if !new_bin.exists() { - return Ok(err_response(r#"{"success":false,"error":"Binary not found in archive"}"#)); + return Ok(err_response( + r#"{"success":false,"error":"Binary not found in archive"}"#, + )); } // Atomic replace: write to sibling .tmp, then rename (safe on same FS) @@ -180,42 +206,72 @@ pub async fn handle_verify_setup(config: &Config) -> Result { let mut all_ok = true; let config_path = Config::writable_config_path(); - checks.insert("config_file".into(), json!({ - "ok": config_path.exists(), - "path": config_path.to_string_lossy() - })); + checks.insert( + "config_file".into(), + json!({ + "ok": config_path.exists(), + "path": config_path.to_string_lossy() + }), + ); let check = |v: &Option, is_dir: bool| -> Value { match v { None => json!({ "ok": false, "detail": "not set" }), Some(p) => { - let ok = if is_dir { Path::new(p).is_dir() } else { Path::new(p).exists() }; + let ok = if is_dir { + Path::new(p).is_dir() + } else { + Path::new(p).exists() + }; json!({ "ok": ok, "detail": p }) } } }; - let wine_ok = config.wine_executable.as_ref() - .map(|p| Path::new(p).exists()).unwrap_or(false); - let term_ok = config.terminal_dir.as_ref() - .map(|p| Path::new(p).is_dir()).unwrap_or(false); + let wine_ok = config + .wine_executable + .as_ref() + .map(|p| Path::new(p).exists()) + .unwrap_or(false); + let term_ok = config + .terminal_dir + .as_ref() + .map(|p| Path::new(p).is_dir()) + .unwrap_or(false); - if !wine_ok || !term_ok { all_ok = false; } + if !wine_ok || !term_ok { + all_ok = false; + } - checks.insert("wine_executable".into(), check(&config.wine_executable, false)); + checks.insert( + "wine_executable".into(), + check(&config.wine_executable, false), + ); checks.insert("terminal_dir".into(), check(&config.terminal_dir, true)); checks.insert("experts_dir".into(), check(&config.experts_dir, true)); checks.insert("indicators_dir".into(), check(&config.indicators_dir, true)); checks.insert("scripts_dir".into(), check(&config.scripts_dir, true)); - checks.insert("tester_profiles_dir".into(), check(&config.tester_profiles_dir, true)); + checks.insert( + "tester_profiles_dir".into(), + check(&config.tester_profiles_dir, true), + ); checks.insert("display_mode".into(), json!(config.display_mode)); - checks.insert("reports_dir".into(), json!(config.reports_dir().to_string_lossy().to_string())); - checks.insert("db_path".into(), json!(Config::db_path().to_string_lossy().to_string())); + checks.insert( + "reports_dir".into(), + json!(config.reports_dir().to_string_lossy().to_string()), + ); + checks.insert( + "db_path".into(), + json!(Config::db_path().to_string_lossy().to_string()), + ); let hint = if all_ok { "Environment fully configured and ready".into() } else if !config_path.exists() { - format!("Auto-discovery will run on next request. Config will be written to {}", config_path.display()) + format!( + "Auto-discovery will run on next request. Config will be written to {}", + config_path.display() + ) } else { format!("Fix missing paths in {}", config_path.display()) }; @@ -235,13 +291,13 @@ pub async fn handle_list_symbols(config: &Config) -> Result { // Get active account info let current_account = config.current_account(); let active_server = current_account.as_ref().map(|a| a.server.clone()); - + // Get all available servers for reference let all_servers = config.available_servers(); - + // Get symbols for active server (or all if no active account) let symbols = config.discover_symbols_for_active_account(); - + let hint = if symbols.is_empty() { if active_server.is_some() { "No history data found for the active account's server. Open MT5 and download tick data for the symbols you want to backtest." @@ -251,7 +307,7 @@ pub async fn handle_list_symbols(config: &Config) -> Result { } else { "These symbols have local tick history and can be used for backtesting." }; - + Ok(json!({ "content": [{ "type": "text", "text": json!({ "success": true, @@ -273,16 +329,16 @@ pub async fn handle_list_symbols(config: &Config) -> Result { pub async fn handle_get_active_account(config: &Config) -> Result { let current_account = config.current_account(); let active_server = current_account.as_ref().map(|a| a.server.clone()); - + // Get all available servers let all_servers = config.available_servers(); - + // Get symbols for active server (or all if no active account) let symbols = config.discover_symbols_for_active_account(); - + // Determine readiness for backtesting let ready_for_backtest = current_account.is_some() && !symbols.is_empty(); - + let hint = if current_account.is_none() { "No active MT5 account detected. Open MT5 and login to an account first." } else if symbols.is_empty() { @@ -290,7 +346,7 @@ pub async fn handle_get_active_account(config: &Config) -> Result { } else { "Ready for backtesting. Use these symbols with run_backtest." }; - + Ok(json!({ "content": [{ "type": "text", "text": json!({ "success": true, @@ -334,14 +390,17 @@ struct ConfigStatus { } pub async fn handle_healthcheck(config: &Config, args: &Value) -> Result { - let detailed = args.get("detailed").and_then(|v| v.as_bool()).unwrap_or(false); - + let detailed = args + .get("detailed") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let os_info = detect_os(); let config_status = validate_configuration(config).await; - + let mut healthy = true; let mut issues = Vec::new(); - + if !config_status.config_exists { healthy = false; issues.push("Configuration file not found - run setup to configure"); @@ -354,7 +413,7 @@ pub async fn handle_healthcheck(config: &Config, args: &Value) -> Result healthy = false; issues.push("MT5 directory not found - check installation"); } - + let mut response = json!({ "success": true, "healthy": healthy, @@ -380,7 +439,7 @@ pub async fn handle_healthcheck(config: &Config, args: &Value) -> Result }, "issues": issues, }); - + if detailed { response["detailed"] = json!({ "rust_version": get_rust_version(), @@ -397,7 +456,7 @@ pub async fn handle_healthcheck(config: &Config, args: &Value) -> Result }, }); } - + Ok(json!({ "content": [{ "type": "text", "text": response.to_string() }], "isError": false @@ -407,10 +466,10 @@ pub async fn handle_healthcheck(config: &Config, args: &Value) -> Result fn detect_os() -> OsInfo { let platform = std::env::consts::OS.to_string(); let arch = std::env::consts::ARCH.to_string(); - + let is_macos = platform == "macos"; let is_linux = platform == "linux"; - + let name = if is_macos { get_macos_version().unwrap_or_else(|| "macOS".to_string()) } else if is_linux { @@ -418,7 +477,7 @@ fn detect_os() -> OsInfo { } else { platform.clone() }; - + OsInfo { platform, arch, @@ -441,7 +500,8 @@ fn get_linux_distro() -> Option { std::fs::read_to_string("/etc/os-release") .ok() .and_then(|content| { - content.lines() + content + .lines() .find(|l| l.starts_with("PRETTY_NAME=")) .map(|l| l.replace("PRETTY_NAME=", "").trim_matches('"').to_string()) }) @@ -459,33 +519,45 @@ fn get_rust_version() -> Option { async fn validate_configuration(config: &Config) -> ConfigStatus { let config_path = Config::writable_config_path(); let config_exists = config_path.exists(); - - let wine_found = config.wine_executable.as_ref() + + let wine_found = config + .wine_executable + .as_ref() .map(|p| Path::new(p).exists()) .unwrap_or(false); let wine_path = config.wine_executable.clone(); - - let mt5_dir_found = config.terminal_dir.as_ref() + + let mt5_dir_found = config + .terminal_dir + .as_ref() .map(|p| Path::new(p).is_dir()) .unwrap_or(false); let mt5_dir = config.terminal_dir.clone(); - - let experts_dir_found = config.experts_dir.as_ref() + + let experts_dir_found = config + .experts_dir + .as_ref() .map(|p| Path::new(p).is_dir()) .unwrap_or(false); - - let indicators_dir_found = config.indicators_dir.as_ref() + + let indicators_dir_found = config + .indicators_dir + .as_ref() .map(|p| Path::new(p).is_dir()) .unwrap_or(false); - - let scripts_dir_found = config.scripts_dir.as_ref() + + let scripts_dir_found = config + .scripts_dir + .as_ref() .map(|p| Path::new(p).is_dir()) .unwrap_or(false); - - let tester_profiles_found = config.tester_profiles_dir.as_ref() + + let tester_profiles_found = config + .tester_profiles_dir + .as_ref() .map(|p| Path::new(p).is_dir()) .unwrap_or(false); - + ConfigStatus { config_exists, config_path: config_path.to_string_lossy().to_string(), diff --git a/src/tools/handlers/utility.rs b/src/tools/handlers/utility.rs index 564a0e5..5c53a39 100644 --- a/src/tools/handlers/utility.rs +++ b/src/tools/handlers/utility.rs @@ -1,10 +1,10 @@ +use crate::models::Config; +use crate::storage::ReportDb; use anyhow::{Context, Result}; use serde_json::{json, Value}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; -use crate::models::Config; -use crate::storage::ReportDb; /// OS detection and information #[derive(Debug, Clone)] @@ -61,30 +61,45 @@ fn get_os_context() -> serde_json::Value { /// Returns the canonicalized absolute path on success. fn safe_output_path(user_path: &str, allowed_base: &Path) -> Result { // Resolve the base first (must already exist). - let base = allowed_base.canonicalize() - .with_context(|| format!("allowed base directory does not exist: {}", allowed_base.display()))?; + let base = allowed_base.canonicalize().with_context(|| { + format!( + "allowed base directory does not exist: {}", + allowed_base.display() + ) + })?; // Build the candidate path. If the user supplied an absolute path we use it // as-is; relative paths are joined onto the base so they stay inside it. let candidate = { let p = Path::new(user_path); - if p.is_absolute() { p.to_path_buf() } else { base.join(p) } + if p.is_absolute() { + p.to_path_buf() + } else { + base.join(p) + } }; // Canonicalize the parent directory (the file itself need not exist yet). - let parent = candidate.parent() + let parent = candidate + .parent() .ok_or_else(|| anyhow::anyhow!("output_path has no parent directory"))?; - let canonical_parent = parent.canonicalize() - .with_context(|| format!("output_path parent directory does not exist: {}", parent.display()))?; + let canonical_parent = parent.canonicalize().with_context(|| { + format!( + "output_path parent directory does not exist: {}", + parent.display() + ) + })?; let canonical = canonical_parent.join( - candidate.file_name() + candidate + .file_name() .ok_or_else(|| anyhow::anyhow!("output_path must include a filename"))?, ); if !canonical.starts_with(&base) { return Err(anyhow::anyhow!( "output_path '{}' is outside the allowed directory '{}'", - user_path, base.display() + user_path, + base.display() )); } Ok(canonical) @@ -92,26 +107,32 @@ fn safe_output_path(user_path: &str, allowed_base: &Path) -> Result { /// Check if symbol has sufficient data for date range pub async fn handle_check_symbol_data_status(config: &Config, args: &Value) -> Result { - let symbol = args.get("symbol") + let symbol = args + .get("symbol") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("symbol is required"))?; - - let from_date = args.get("from_date") + + let from_date = args + .get("from_date") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("from_date is required"))?; - - let to_date = args.get("to_date") + + let to_date = args + .get("to_date") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("to_date is required"))?; - + // Get active account to determine which server to check let current_account = config.current_account(); - let server = current_account.as_ref().map(|a| a.server.as_str()).unwrap_or(""); - + let server = current_account + .as_ref() + .map(|a| a.server.as_str()) + .unwrap_or(""); + // Check if symbol exists in available symbols let available_symbols = config.discover_symbols_for_active_account(); let symbol_available = available_symbols.contains(&symbol.to_string()); - + if !symbol_available { return Ok(json!({ "content": [{ "type": "text", "text": json!({ @@ -124,13 +145,13 @@ pub async fn handle_check_symbol_data_status(config: &Config, args: &Value) -> R "isError": false })); } - + // Try to find hcc files to determine actual data range let mt5_dir = config.mt5_dir(); let mut data_range_start = None; let mut data_range_end = None; let mut bars_count = 0; - + if let Some(mt5_path) = mt5_dir { let bases_dir = mt5_path.join("Bases"); if bases_dir.exists() { @@ -147,11 +168,17 @@ pub async fn handle_check_symbol_data_status(config: &Config, args: &Value) -> R bars_count += 1; if let Some(fname) = entry.file_name().to_str() { // Parse year from filename (e.g., "2024.hcc") - if let Ok(year) = fname.trim_end_matches(".hcc").parse::() { - if data_range_start.is_none() || year < data_range_start.unwrap() { + if let Ok(year) = + fname.trim_end_matches(".hcc").parse::() + { + if data_range_start.is_none() + || year < data_range_start.unwrap() + { data_range_start = Some(year); } - if data_range_end.is_none() || year > data_range_end.unwrap() { + if data_range_end.is_none() + || year > data_range_end.unwrap() + { data_range_end = Some(year); } } @@ -164,7 +191,7 @@ pub async fn handle_check_symbol_data_status(config: &Config, args: &Value) -> R } } } - + // Parse requested date range let parse_date = |date_str: &str| -> Option<(i32, i32, i32)> { let parts: Vec<&str> = date_str.split('.').collect(); @@ -175,26 +202,34 @@ pub async fn handle_check_symbol_data_status(config: &Config, args: &Value) -> R } None }; - + let req_from = parse_date(from_date); let req_to = parse_date(to_date); - + let mut has_sufficient = true; let mut warnings = Vec::new(); - - if let (Some(req_year), Some(start_year), Some(end_year)) = (req_from.map(|d| d.0), data_range_start, data_range_end) { + + if let (Some(req_year), Some(start_year), Some(end_year)) = + (req_from.map(|d| d.0), data_range_start, data_range_end) + { if req_year < start_year { has_sufficient = false; - warnings.push(format!("Requested start year {} is before available data start {}", req_year, start_year)); + warnings.push(format!( + "Requested start year {} is before available data start {}", + req_year, start_year + )); } if let Some(req_to_year) = req_to.map(|d| d.0) { if req_to_year > end_year { has_sufficient = false; - warnings.push(format!("Requested end year {} is after available data end {}", req_to_year, end_year)); + warnings.push(format!( + "Requested end year {} is after available data end {}", + req_to_year, end_year + )); } } } - + Ok(json!({ "content": [{ "type": "text", "text": json!({ "symbol": symbol, @@ -222,10 +257,10 @@ pub async fn handle_get_backtest_history(config: &Config, args: &Value) -> Resul let expert_filter = args.get("expert").and_then(|v| v.as_str()); let symbol_filter = args.get("symbol").and_then(|v| v.as_str()); let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; - + let reports_dir = config.reports_dir(); let mut history = Vec::new(); - + if reports_dir.exists() { for entry in fs::read_dir(&reports_dir)?.flatten() { let path = entry.path(); @@ -236,7 +271,7 @@ pub async fn handle_get_backtest_history(config: &Config, args: &Value) -> Resul if let Ok(metrics) = serde_json::from_str::(&content) { let report_expert = metrics.get("expert").and_then(|v| v.as_str()); let report_symbol = metrics.get("symbol").and_then(|v| v.as_str()); - + // Apply filters if let (Some(e), Some(filter)) = (report_expert, expert_filter) { if !e.contains(filter) { @@ -248,7 +283,7 @@ pub async fn handle_get_backtest_history(config: &Config, args: &Value) -> Resul continue; } } - + // Extract key metrics let summary = json!({ "report_dir": path.file_name().and_then(|n| n.to_str()).unwrap_or(""), @@ -263,25 +298,25 @@ pub async fn handle_get_backtest_history(config: &Config, args: &Value) -> Resul "total_trades": metrics.get("total_trades").and_then(|v| v.as_u64()), "win_rate": metrics.get("win_rate").and_then(|v| v.as_f64()), }); - + history.push(summary); } } } } } - + // Sort by date (newest first) history.sort_by(|a, b| { let date_a = a.get("date").and_then(|v| v.as_str()).unwrap_or(""); let date_b = b.get("date").and_then(|v| v.as_str()).unwrap_or(""); date_b.cmp(date_a) }); - + // Apply limit let total = history.len(); let limited: Vec = history.into_iter().take(limit).collect(); - + Ok(json!({ "content": [{ "type": "text", "text": json!({ "success": true, @@ -300,10 +335,11 @@ pub async fn handle_get_backtest_history(config: &Config, args: &Value) -> Resul /// Compare multiple backtests pub async fn handle_compare_backtests(_config: &Config, args: &Value) -> Result { - let report_dirs = args.get("report_dirs") + let report_dirs = args + .get("report_dirs") .and_then(|v| v.as_array()) .ok_or_else(|| anyhow::anyhow!("report_dirs array is required"))?; - + if report_dirs.len() < 2 { return Ok(json!({ "content": [{ "type": "text", "text": json!({ @@ -312,21 +348,21 @@ pub async fn handle_compare_backtests(_config: &Config, args: &Value) -> Result< "isError": true })); } - + let mut comparisons = Vec::new(); let mut base_metrics: Option = None; - + for dir_value in report_dirs { let dir = dir_value.as_str().unwrap_or(""); let path = Path::new(dir); let metrics_path = path.join("metrics.json"); - + if let Ok(content) = fs::read_to_string(&metrics_path) { if let Ok(metrics) = serde_json::from_str::(&content) { if base_metrics.is_none() { base_metrics = Some(metrics.clone()); } - + let summary = json!({ "report_dir": dir, "expert": metrics.get("expert").and_then(|v| v.as_str()), @@ -340,24 +376,42 @@ pub async fn handle_compare_backtests(_config: &Config, args: &Value) -> Result< "recovery_factor": metrics.get("recovery_factor").and_then(|v| v.as_f64()), "sharpe_ratio": metrics.get("sharpe_ratio").and_then(|v| v.as_f64()), }); - + comparisons.push(summary); } } } - + // Calculate differences if we have base let mut analysis = Vec::new(); if let Some(base) = &base_metrics { - let base_profit = base.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0); - let base_dd = base.get("drawdown_pct").and_then(|v| v.as_f64()).unwrap_or(0.0); - let base_pf = base.get("profit_factor").and_then(|v| v.as_f64()).unwrap_or(0.0); - + let base_profit = base + .get("net_profit") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let base_dd = base + .get("drawdown_pct") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let base_pf = base + .get("profit_factor") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + for (_i, comp) in comparisons.iter().enumerate().skip(1) { - let profit = comp.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0); - let dd = comp.get("drawdown_pct").and_then(|v| v.as_f64()).unwrap_or(0.0); - let pf = comp.get("profit_factor").and_then(|v| v.as_f64()).unwrap_or(0.0); - + let profit = comp + .get("net_profit") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let dd = comp + .get("drawdown_pct") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + let pf = comp + .get("profit_factor") + .and_then(|v| v.as_f64()) + .unwrap_or(0.0); + analysis.push(json!({ "compare_to": comparisons[0].get("report_dir"), "report": comp.get("report_dir"), @@ -371,7 +425,7 @@ pub async fn handle_compare_backtests(_config: &Config, args: &Value) -> Result< })); } } - + Ok(json!({ "content": [{ "type": "text", "text": json!({ "success": true, @@ -385,7 +439,7 @@ pub async fn handle_compare_backtests(_config: &Config, args: &Value) -> Result< let best_idx = profits.iter().enumerate() .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal)) .map(|(i, _)| i); - best_idx.map(|i| format!("Best: {}", + best_idx.map(|i| format!("Best: {}", comparisons.get(i).and_then(|c| c.get("report_dir").and_then(|v| v.as_str())).unwrap_or("unknown"))) } else { None } }).to_string() }], @@ -395,23 +449,30 @@ pub async fn handle_compare_backtests(_config: &Config, args: &Value) -> Result< /// Initialize new MQL5 project pub async fn handle_init_project(config: &Config, args: &Value) -> Result { - let name = args.get("name") + let name = args + .get("name") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("name is required"))?; - - let template = args.get("template").and_then(|v| v.as_str()).unwrap_or("basic"); - + + let template = args + .get("template") + .and_then(|v| v.as_str()) + .unwrap_or("basic"); + // Determine project directory - let project_dir = config.project_dir.as_ref() + let project_dir = config + .project_dir + .as_ref() .map(|p| Path::new(p).to_path_buf()) .or_else(|| std::env::current_dir().ok()) .ok_or_else(|| anyhow::anyhow!("Cannot determine project directory"))?; - + let ea_path = project_dir.join(format!("{}.mq5", name)); - + // Basic EA template let ea_template = match template { - "scalper" => format!(r#"//+------------------------------------------------------------------+ + "scalper" => format!( + r#"//+------------------------------------------------------------------+ //| {}.mq5 | //| Scalper EA Template | //+------------------------------------------------------------------+ @@ -456,9 +517,12 @@ void OnTick() {{ }} }} }} -"#, name, name, name), - - "swing" => format!(r#"//+------------------------------------------------------------------+ +"#, + name, name, name + ), + + "swing" => format!( + r#"//+------------------------------------------------------------------+ //| {}.mq5 | //| Swing EA Template | //+------------------------------------------------------------------+ @@ -488,9 +552,12 @@ void OnTick() {{ // Implementation here }} -"#, name, name, name), - - "grid" => format!(r#"//+------------------------------------------------------------------+ +"#, + name, name, name + ), + + "grid" => format!( + r#"//+------------------------------------------------------------------+ //| {}.mq5 | //| Grid EA Template | //+------------------------------------------------------------------+ @@ -517,9 +584,12 @@ void OnTick() {{ // Grid trading logic // WARNING: Grid strategies can lead to significant losses }} -"#, name, name, name), - - _ => format!(r#"//+------------------------------------------------------------------+ +"#, + name, name, name + ), + + _ => format!( + r#"//+------------------------------------------------------------------+ //| {}.mq5 | //| Basic EA Template | //+------------------------------------------------------------------+ @@ -545,15 +615,18 @@ void OnTick() {{ // Your trading logic here // Check for open positions, signals, etc. }} -"#, name, name, name) +"#, + name, name, name + ), }; - + // Write EA file fs::write(&ea_path, ea_template)?; - + // Create README let readme_path = project_dir.join("README.md"); - let readme_content = format!(r#"# {} + let readme_content = format!( + r#"# {} MQL5 Expert Advisor generated by MT5-Quant @@ -574,10 +647,12 @@ mt5-quant compile_ea expert={} # Run backtest mt5-quant run_backtest expert={} symbol=XAUUSDc ``` -"#, name, name, name, name); - +"#, + name, name, name, name + ); + fs::write(&readme_path, readme_content)?; - + Ok(json!({ "content": [{ "type": "text", "text": json!({ "success": true, @@ -595,26 +670,27 @@ mt5-quant run_backtest expert={} symbol=XAUUSDc /// Validate EA syntax (basic check without full compile) pub async fn handle_validate_ea_syntax(_config: &Config, args: &Value) -> Result { - let path = args.get("path") + let path = args + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("path is required"))?; - + let content = fs::read_to_string(path)?; let mut errors = Vec::new(); let mut warnings = Vec::new(); - + // Basic syntax checks let lines: Vec<&str> = content.lines().collect(); - + for (i, line) in lines.iter().enumerate() { let line_num = i + 1; let trimmed = line.trim(); - + // Check for basic issues if trimmed.ends_with('{') && !trimmed.contains('}') { // This is just a heuristic, not a real syntax error } - + if trimmed.contains("OrderSend") && !trimmed.starts_with("//") { warnings.push(json!({ "line": line_num, @@ -622,23 +698,25 @@ pub async fn handle_validate_ea_syntax(_config: &Config, args: &Value) -> Result "severity": "warning" })); } - - if trimmed.contains("input") && trimmed.contains("double") && trimmed.contains("Lots") { - if !trimmed.contains("=") { - warnings.push(json!({ - "line": line_num, - "message": "Input parameter 'Lots' has no default value", - "severity": "warning" - })); - } + + if trimmed.contains("input") + && trimmed.contains("double") + && trimmed.contains("Lots") + && !trimmed.contains("=") + { + warnings.push(json!({ + "line": line_num, + "message": "Input parameter 'Lots' has no default value", + "severity": "warning" + })); } } - + // Check for required sections let has_on_init = content.contains("int OnInit()"); let has_on_tick = content.contains("void OnTick()"); let has_on_deinit = content.contains("void OnDeinit"); - + if !has_on_init { errors.push(json!({ "line": 0, @@ -646,7 +724,7 @@ pub async fn handle_validate_ea_syntax(_config: &Config, args: &Value) -> Result "severity": "error" })); } - + if !has_on_tick && !content.contains("void OnTimer()") { warnings.push(json!({ "line": 0, @@ -654,9 +732,9 @@ pub async fn handle_validate_ea_syntax(_config: &Config, args: &Value) -> Result "severity": "warning" })); } - + let valid = errors.is_empty(); - + Ok(json!({ "content": [{ "type": "text", "text": json!({ "success": valid, @@ -680,24 +758,33 @@ pub async fn handle_validate_ea_syntax(_config: &Config, args: &Value) -> Result pub async fn handle_check_mt5_status(config: &Config) -> Result { let mt5_dir = config.mt5_dir(); let wine_exe = config.wine_executable.as_ref(); - + // Check if MT5 files exist - let terminal_exists = mt5_dir.as_ref().map(|d| d.join("terminal64.exe").exists()).unwrap_or(false); - let metaeditor_exists = mt5_dir.as_ref().map(|d| d.join("metaeditor64.exe").exists()).unwrap_or(false); - let tester_exists = mt5_dir.as_ref().map(|d| d.join("metatester64.exe").exists()).unwrap_or(false); - + let terminal_exists = mt5_dir + .as_ref() + .map(|d| d.join("terminal64.exe").exists()) + .unwrap_or(false); + let metaeditor_exists = mt5_dir + .as_ref() + .map(|d| d.join("metaeditor64.exe").exists()) + .unwrap_or(false); + let tester_exists = mt5_dir + .as_ref() + .map(|d| d.join("metatester64.exe").exists()) + .unwrap_or(false); + // Check Wine let wine_ok = wine_exe.map(|w| Path::new(w).exists()).unwrap_or(false); - + // Try to get MT5 version (would need to actually run it, skip for now) let mut mt5_version = None; if wine_ok && terminal_exists { // Could run: wine terminal64.exe /version but it's complex mt5_version = Some("detected".to_string()); } - + let all_ok = terminal_exists && metaeditor_exists && tester_exists && wine_ok; - + Ok(json!({ "content": [{ "type": "text", "text": json!({ "success": all_ok, @@ -715,10 +802,10 @@ pub async fn handle_check_mt5_status(config: &Config) -> Result { "login": a.login, "server": a.server })), - "hint": if all_ok { - "MT5 is ready for backtesting and optimization." - } else { - "Some components missing. Run verify_setup for detailed diagnostics." + "hint": if all_ok { + "MT5 is ready for backtesting and optimization." + } else { + "Some components missing. Run verify_setup for detailed diagnostics." } }).to_string() }], "isError": false @@ -727,12 +814,13 @@ pub async fn handle_check_mt5_status(config: &Config) -> Result { /// Create .set file template from EA pub async fn handle_create_set_template(config: &Config, args: &Value) -> Result { - let ea = args.get("ea") + let ea = args + .get("ea") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("ea is required"))?; - + let output_path = args.get("output_path").and_then(|v| v.as_str()); - + // Find EA source file let ea_path = if Path::new(ea).exists() { Path::new(ea).to_path_buf() @@ -741,37 +829,39 @@ pub async fn handle_create_set_template(config: &Config, args: &Value) -> Result } else { return Err(anyhow::anyhow!("Cannot find EA: {}", ea)); }; - + if !ea_path.exists() { return Err(anyhow::anyhow!("EA file not found: {}", ea_path.display())); } - + let content = fs::read_to_string(&ea_path)?; let mut inputs = Vec::new(); - + // Parse input declarations for line in content.lines() { let trimmed = line.trim(); - if trimmed.starts_with("input ") { + if let Some(without_input) = trimmed.strip_prefix("input ") { // Parse: input type name = default; // comment - let without_input = &trimmed[6..]; // Remove "input " - + // Extract comment if any let parts: Vec<&str> = without_input.split("//").collect(); let decl = parts[0].trim(); let comment = parts.get(1).map(|c| c.trim()); - + // Parse type and name let tokens: Vec<&str> = decl.split_whitespace().collect(); if tokens.len() >= 2 { let type_name = tokens[0]; let rest = tokens[1..].to_vec().join(" "); - + // Parse name = value let name_value: Vec<&str> = rest.split('=').collect(); let name = name_value[0].trim(); - let default_val = name_value.get(1).map(|v| v.trim().trim_end_matches(';')).unwrap_or("0"); - + let default_val = name_value + .get(1) + .map(|v| v.trim().trim_end_matches(';')) + .unwrap_or("0"); + inputs.push(json!({ "name": name, "type": type_name, @@ -781,22 +871,25 @@ pub async fn handle_create_set_template(config: &Config, args: &Value) -> Result } } } - + // Generate .set content let mut set_content = format!("; {} parameters generated by MT5-Quant\n", ea); set_content.push_str("; Format: name=value\n\n"); - + for input in &inputs { let name = input.get("name").and_then(|v| v.as_str()).unwrap_or(""); let default = input.get("default").and_then(|v| v.as_str()).unwrap_or("0"); - let desc = input.get("description").and_then(|v| v.as_str()).unwrap_or(""); - + let desc = input + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !desc.is_empty() { set_content.push_str(&format!("; {}\n", desc)); } set_content.push_str(&format!("{}={}\n\n", name, default)); } - + // Determine output path let set_path = if let Some(path) = output_path { // Restrict writes to the tester profiles dir (or the EA's own dir as fallback). @@ -811,9 +904,9 @@ pub async fn handle_create_set_template(config: &Config, args: &Value) -> Result } else { ea_path.with_extension("set") }; - + fs::write(&set_path, set_content)?; - + Ok(json!({ "content": [{ "type": "text", "text": json!({ "success": true, @@ -829,16 +922,17 @@ pub async fn handle_create_set_template(config: &Config, args: &Value) -> Result /// Export backtest report to various formats pub async fn handle_export_report(_config: &Config, args: &Value) -> Result { - let report_dir = args.get("report_dir") + let report_dir = args + .get("report_dir") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("report_dir is required"))?; - + let format = args.get("format").and_then(|v| v.as_str()).unwrap_or("csv"); let output_path = args.get("output_path").and_then(|v| v.as_str()); - + let path = Path::new(report_dir); let metrics_path = path.join("metrics.json"); - + // Read metrics let metrics: Value = if metrics_path.exists() { fs::read_to_string(&metrics_path) @@ -848,13 +942,13 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result safe_output_path(p, path)?, None => path.join(format!("report.{}", format)), }; - + let content = match format { "csv" => { // Simple CSV export of metrics @@ -868,7 +962,13 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result { // Markdown format - let mut md = format!("# Backtest Report: {}\n\n", metrics.get("expert").and_then(|v| v.as_str()).unwrap_or("Unknown")); + let mut md = format!( + "# Backtest Report: {}\n\n", + metrics + .get("expert") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown") + ); md.push_str("## Summary\n\n"); if let Some(obj) = metrics.as_object() { for (key, value) in obj { @@ -877,11 +977,11 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result metrics.to_string() // JSON fallback + _ => metrics.to_string(), // JSON fallback }; - + fs::write(&output, content)?; - + Ok(json!({ "content": [{ "type": "text", "text": json!({ "success": true, @@ -911,20 +1011,18 @@ pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result::new(), "warnings": Vec::::new(), }); - + // Check wine executable (macOS/Linux) if let Some(wine_exe) = config.wine_executable.as_ref() { diagnostics["wine_executable"] = json!(wine_exe); - + // Detect CrossOver vs Wine let is_crossover = detect_crossover(wine_exe); diagnostics["wine_type"] = json!(if is_crossover { "crossover" } else { "wine" }); - + // Get Wine/CrossOver version - let version_output = Command::new(wine_exe) - .arg("--version") - .output(); - + let version_output = 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(); @@ -932,42 +1030,47 @@ pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result { let wine_type_str = if is_crossover { "CrossOver" } else { "Wine" }; - diagnostics["errors"].as_array_mut().unwrap().push( - json!(format!("Failed to get {} version - may not be properly installed", wine_type_str)) - ); + diagnostics["errors"] + .as_array_mut() + .unwrap() + .push(json!(format!( + "Failed to get {} version - may not be properly installed", + wine_type_str + ))); } } - + // macOS-specific CrossOver checks if matches!(os_type, OsType::MacOS) && is_crossover { // Check if CrossOver app exists let crossover_app = Path::new("/Applications/CrossOver.app"); if !crossover_app.exists() { - diagnostics["warnings"].as_array_mut().unwrap().push( - json!("CrossOver.app not found in /Applications - may be custom installation") - ); + diagnostics["warnings"].as_array_mut().unwrap().push(json!( + "CrossOver.app not found in /Applications - may be custom installation" + )); } } } else { - diagnostics["errors"].as_array_mut().unwrap().push( - json!("Wine/CrossOver executable not configured") - ); + diagnostics["errors"] + .as_array_mut() + .unwrap() + .push(json!("Wine/CrossOver 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; @@ -987,30 +1090,32 @@ pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result = content.lines() + let recent_errors: Vec<&str> = content + .lines() .filter(|l| l.contains("err:") || l.contains("fixme:")) .rev() .take(10) @@ -1021,24 +1126,27 @@ pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result Result Result { - let log_type = args.get("log_type").and_then(|v| v.as_str()).unwrap_or("terminal"); + 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() + + 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"))), + "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!({ "os_context": get_os_context(), "log_type": log_type, @@ -1070,7 +1184,7 @@ pub async fn handle_get_mt5_logs(config: &Config, args: &Value) -> Result "lines_returned": 0, "content": Vec::::new(), }); - + // Find log files let mut log_files: Vec<_> = Vec::new(); if log_path.exists() { @@ -1091,35 +1205,36 @@ pub async fn handle_get_mt5_logs(config: &Config, args: &Value) -> Result } } } - + // Sort by modification time (newest first) log_files.sort_by(|a, b| b.1.cmp(&a.1)); - + if let Some((latest_log, _)) = log_files.first() { result["found"] = json!(true); - + if let Ok(content) = fs::read_to_string(latest_log) { let all_lines: Vec<&str> = content.lines().collect(); result["lines_total"] = json!(all_lines.len()); - + // Filter and limit lines let mut filtered: Vec<&str> = all_lines.clone(); - + // Apply search filter if let Some(search_term) = search { let search_lower = search_term.to_lowercase(); filtered.retain(|line| line.to_lowercase().contains(&search_lower)); } - + // Get last N lines let start = filtered.len().saturating_sub(lines); - let final_lines: Vec = filtered[start..].iter().map(|s| s.to_string()).collect(); - + let final_lines: Vec = + filtered[start..].iter().map(|s| s.to_string()).collect(); + result["lines_returned"] = json!(final_lines.len()); result["content"] = json!(final_lines); } } - + Ok(json!({ "content": [{ "type": "text", "text": result.to_string() }], "isError": false @@ -1129,19 +1244,34 @@ pub async fn handle_get_mt5_logs(config: &Config, args: &Value) -> Result /// Search MT5 logs for error patterns pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result { let error_patterns = vec![ - "error", "failed", "crash", "exception", "access violation", - "out of memory", "cannot", "unable to", "terminated" + "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() + 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); - + 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) { @@ -1179,7 +1309,7 @@ pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result Result Result Result { let _os_type = OsType::detect(); - + let mut result = json!({ "os_context": get_os_context(), "is_running": false, @@ -1210,14 +1340,12 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result "wine_server_running": false, "total_instances": 0, }); - + #[cfg(any(target_os = "macos", target_os = "linux"))] { // Check for MT5 processes - let ps_output = Command::new("ps") - .args(["aux"]) - .output(); - + 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(); @@ -1225,10 +1353,10 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result let mut wine_server = false; #[cfg(target_os = "macos")] let mut crossover_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 { @@ -1241,30 +1369,30 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result mt5_count += 1; } } - + if line_lower.contains("wineserver") { wine_server = true; } - + // Detect CrossOver server on macOS #[cfg(target_os = "macos")] if line_lower.contains("cxstart") || line_lower.contains("crossover") { crossover_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 = "macos")] { result["crossover_server_running"] = json!(crossover_server); } } } - + Ok(json!({ "content": [{ "type": "text", "text": result.to_string() }], "isError": false @@ -1274,38 +1402,37 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result /// Kill stuck MT5 process pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result { let _os_type = OsType::detect(); - + 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(); - + 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")) + 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)), @@ -1314,11 +1441,11 @@ pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result Result Result Result Result { let _os_type = OsType::detect(); - + let mut result = json!({ "os_context": get_os_context(), "disk_space": null, @@ -1358,14 +1485,12 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R "cpu_cores": 0, "recommendations": Vec::::new(), }); - + #[cfg(any(target_os = "macos", target_os = "linux"))] { // Check disk space - let df_output = Command::new("df") - .args(["-h", "/"]) - .output(); - + 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) { @@ -1378,22 +1503,26 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R "available": parts[3], "use_percent": parts[4], }); - + // Check if low on space let use_pct = parts[4].trim_end_matches('%').parse::().unwrap_or(0); if use_pct > 90 { - result["recommendations"].as_array_mut().unwrap().push( - json!("Disk space critically low. Clean MT5 cache with clean_cache.") - ); + 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.") - ); + result["recommendations"] + .as_array_mut() + .unwrap() + .push(json!("Disk space getting low. Consider cleaning cache.")); } } } } - + // Check memory #[cfg(target_os = "macos")] { @@ -1406,7 +1535,7 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R let mut inactive_pages = 0u64; let mut wired_pages = 0u64; let mut page_size = 4096u64; - + // Try to get page size from vm_stat output for line in content.lines() { if line.contains("Mach Virtual Memory Statistics:") { @@ -1414,7 +1543,7 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R page_size = 4096; } } - + for line in content.lines() { // More robust parsing using regex-like pattern matching if line.contains("Pages free:") { @@ -1435,13 +1564,13 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R } } } - + // Calculate memory more accurately let total_pages = free_pages + active_pages + inactive_pages + wired_pages; let total_mb = (total_pages * page_size) / 1024 / 1024; let free_mb = (free_pages * page_size) / 1024 / 1024; let available_mb = ((free_pages + inactive_pages) * page_size) / 1024 / 1024; - + result["memory"] = json!({ "total_mb": total_mb, "free_mb": free_mb, @@ -1451,7 +1580,7 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R "page_size": page_size, "unit": "MB", }); - + // Adjust memory threshold for macOS (compressed memory makes free appear lower) if available_mb < 1024 { result["recommendations"].as_array_mut().unwrap().push( @@ -1464,7 +1593,7 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R } } } - + #[cfg(target_os = "linux")] { let mem_output = Command::new("free").args(["-m"]).output(); @@ -1480,7 +1609,7 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R "free_mb": parts[3].parse::().unwrap_or(0), "unit": "MB", }); - + let free_mb = parts[3].parse::().unwrap_or(0); if free_mb < 1024 { result["recommendations"].as_array_mut().unwrap().push( @@ -1492,43 +1621,55 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R } } } - + // Get CPU cores #[cfg(target_os = "macos")] { - let nproc_output = Command::new("sysctl") - .args(["-n", "hw.ncpu"]) - .output(); - + 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); + let cores = String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap_or(0); result["cpu_cores"] = json!(cores); - + if cores < 4 { - result["recommendations"].as_array_mut().unwrap().push( - json!(format!("Low CPU core count ({} cores). Optimizations may be slow.", cores)) - ); + result["recommendations"] + .as_array_mut() + .unwrap() + .push(json!(format!( + "Low CPU core count ({} cores). Optimizations may be slow.", + cores + ))); } } } - + #[cfg(target_os = "linux")] { let nproc_output = Command::new("nproc").output(); - + if let Ok(output) = nproc_output { - let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0); + let cores = String::from_utf8_lossy(&output.stdout) + .trim() + .parse() + .unwrap_or(0); result["cpu_cores"] = json!(cores); - + if cores < 4 { - result["recommendations"].as_array_mut().unwrap().push( - json!(format!("Low CPU core count ({} cores). Optimizations may be slow.", cores)) - ); + result["recommendations"] + .as_array_mut() + .unwrap() + .push(json!(format!( + "Low CPU core count ({} cores). Optimizations may be slow.", + cores + ))); } } } } - + Ok(json!({ "content": [{ "type": "text", "text": result.to_string() }], "isError": false @@ -1537,9 +1678,10 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R /// Validate MT5 configuration files pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Result { - let mt5_dir = config.mt5_dir() + let mt5_dir = config + .mt5_dir() .ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?; - + let mut result = json!({ "os_context": get_os_context(), "terminal_ini": null, @@ -1548,43 +1690,58 @@ pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Resul "errors": Vec::::new(), "warnings": Vec::::new(), }); - + // Check terminal.ini let terminal_ini = mt5_dir.join("terminal.ini"); if terminal_ini.exists() { - result["config_files_found"].as_array_mut().unwrap().push(json!("terminal.ini")); - + 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") - ); + 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="))); + 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="))); + 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="))); + 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") - ); + 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") - ); + 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() { @@ -1600,23 +1757,23 @@ pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Resul } }) .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") - ); + 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 @@ -1626,14 +1783,15 @@ pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Resul /// Get Wine prefix detailed information pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Result { let _os_type = OsType::detect(); - let mt5_dir = config.mt5_dir() + 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!({ "os_context": get_os_context(), "prefix_path": null, @@ -1644,11 +1802,11 @@ pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Resu "registry_files": Vec::::new(), "drive_c_size_mb": 0, }); - + if let Some(prefix) = wine_prefix { result["prefix_path"] = json!(prefix.to_string_lossy().to_string()); result["exists"] = json!(prefix.exists()); - + if prefix.exists() { // Check Windows version let system_reg = prefix.join("system.reg"); @@ -1664,7 +1822,7 @@ pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Resu } } } - + // Calculate drive_c size let drive_c = prefix.join("drive_c"); if drive_c.exists() { @@ -1686,7 +1844,7 @@ pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Resu 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() { @@ -1705,19 +1863,17 @@ pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Resu result["installed_programs"] = json!(programs); } } - + // List registry files - let reg_files: Vec = vec![ - "system.reg", "user.reg", "userdef.reg" - ] - .into_iter() - .filter(|f| prefix.join(f).exists()) - .map(|f| f.to_string()) - .collect(); + let reg_files: Vec = vec!["system.reg", "user.reg", "userdef.reg"] + .into_iter() + .filter(|f| prefix.join(f).exists()) + .map(|f| f.to_string()) + .collect(); result["registry_files"] = json!(reg_files); } } - + Ok(json!({ "content": [{ "type": "text", "text": result.to_string() }], "isError": false @@ -1727,16 +1883,19 @@ pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Resu /// Get backtest crash/failure information pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Result { let report_dir = args.get("report_dir").and_then(|v| v.as_str()); - let check_recent = args.get("check_recent").and_then(|v| v.as_bool()).unwrap_or(true); + let 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!({ "os_context": get_os_context(), "crashes_found": Vec::::new(), "recent_failures": 0, "common_patterns": Vec::::new(), }); - + // Check specific report directory if provided if let Some(dir) = report_dir { let path = Path::new(dir); @@ -1744,7 +1903,7 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re // 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, @@ -1752,7 +1911,7 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re "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!({ @@ -1762,15 +1921,13 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re })); } } - + // Check deal count in DB let db = ReportDb::new(&Config::db_path()); if db.init().is_ok() { match db.get_by_report_dir(dir) { Ok(Some(entry)) => { - let deal_count = db.get_deals(&entry.id) - .map(|d| d.len()) - .unwrap_or(0); + let deal_count = db.get_deals(&entry.id).map(|d| d.len()).unwrap_or(0); if deal_count == 0 { result["crashes_found"].as_array_mut().unwrap().push(json!({ "report_dir": dir, @@ -1794,15 +1951,16 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re } } } - + // 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 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(); @@ -1826,26 +1984,34 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re } } } - + // Analyze common patterns let crashes = result["crashes_found"].as_array().unwrap(); if !crashes.is_empty() { - let types: Vec = crashes.iter() - .filter_map(|c| c.get("type").and_then(|v| v.as_str()).map(|s| s.to_string())) + let types: Vec = crashes + .iter() + .filter_map(|c| { + c.get("type") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + }) .collect(); - + if types.contains(&"missing_deals".to_string()) { result["common_patterns"].as_array_mut().unwrap().push( json!("Report not in DB and no metrics.json suggests MT5 crashed before extraction completed") ); } if types.contains(&"incomplete".to_string()) { - result["common_patterns"].as_array_mut().unwrap().push( - json!("Incomplete markers indicate interruptions - check system resources") - ); + 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 diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index cac951e..1a452b2 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -33,7 +33,10 @@ fn test_sample_deals_csv_format() { // Check for expected columns in header let header = lines[0]; - assert!(header.contains("Time") || header.contains("time"), "Header should contain Time column"); + assert!( + header.contains("Time") || header.contains("time"), + "Header should contain Time column" + ); } #[test] @@ -42,8 +45,10 @@ fn test_sample_report_html_format() { let content = fs::read_to_string(path).expect("Should read sample_report.htm"); // Check HTML structure - assert!(content.contains("