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)
This commit is contained in:
+2
-2
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
|
||||
+182
-50
@@ -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<usize>) -> DrawdownEvent {
|
||||
fn build_dd_event(
|
||||
&self,
|
||||
curve: &[(String, f64, f64, f64, String)],
|
||||
start_idx: usize,
|
||||
peak_idx: usize,
|
||||
recovery_idx: Option<usize>,
|
||||
) -> 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<DateTime<chrono::Utc>> {
|
||||
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<LayerPerformance> = layer_stats
|
||||
.into_iter()
|
||||
.map(|(layer, (trades, wins, total_pnl, total_volume))| LayerPerformance {
|
||||
layer,
|
||||
trades,
|
||||
wins,
|
||||
total_pnl: (total_pnl * 100.0).round() / 100.0,
|
||||
win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 },
|
||||
avg_volume: if trades > 0 { (total_volume / trades as f64 * 10000.0).round() / 10000.0 } else { 0.0 },
|
||||
avg_profit: if trades > 0 { (total_pnl / trades as f64 * 100.0).round() / 100.0 } else { 0.0 },
|
||||
})
|
||||
.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<DateTime<chrono::Utc>> = 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,
|
||||
|
||||
+103
-49
@@ -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"<?xml") || header.windows(8).any(|w| w == b"Workbook") {
|
||||
if header.windows(5).any(|w| w == b"<?xml")
|
||||
|| header.windows(8).any(|w| w == b"Workbook")
|
||||
{
|
||||
return ReportFormat::Xml;
|
||||
}
|
||||
}
|
||||
@@ -54,10 +56,10 @@ impl ReportExtractor {
|
||||
|
||||
fn parse_html(&self, path: &str) -> Result<(Metrics, Vec<Deal>)> {
|
||||
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<Deal>)> {
|
||||
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 <tr> 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 </")) &&
|
||||
(lower.contains(">symbol<") || lower.contains(">symbol </"))
|
||||
(lower.contains(">deal<") || lower.contains(">deal </"))
|
||||
&& (lower.contains(">symbol<") || lower.contains(">symbol </"))
|
||||
});
|
||||
|
||||
let start_idx = match header_idx {
|
||||
@@ -108,7 +110,9 @@ impl ReportExtractor {
|
||||
// Fallback: look for any row containing Time+Volume+Profit headers
|
||||
let alt = rows.iter().position(|row| {
|
||||
let lower = row.to_lowercase();
|
||||
lower.contains(">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<String> = cell_re.captures_iter(row)
|
||||
let cells: Vec<String> = 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"<Row[^>]*>(.*?)</Row>")
|
||||
.map_err(|e| anyhow!("Regex error: {}", e))?;
|
||||
|
||||
|
||||
let cell_re = regex::Regex::new(r"<Cell[^>]*>.*?<Data[^>]*>(.*?)</Data>.*?</Cell>")
|
||||
.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<String> = cell_re.captures_iter(row)
|
||||
|
||||
let cells: Vec<String> = 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<String> {
|
||||
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::<Vec<_>>()
|
||||
.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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+94
-34
@@ -30,28 +30,42 @@ impl MqlCompiler {
|
||||
}
|
||||
|
||||
pub async fn compile(&self, source_path: &str) -> Result<CompileResult> {
|
||||
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<CompileResult> {
|
||||
pub async fn compile_with_timeout(
|
||||
&self,
|
||||
source_path: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<CompileResult> {
|
||||
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<String> = log_text.lines()
|
||||
let errors: Vec<String> = 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<String> = log_text.lines()
|
||||
let warnings: Vec<String> = 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()
|
||||
|
||||
+31
-30
@@ -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<u16>,
|
||||
|
||||
|
||||
/// Test backtest launch performance (direct Rust call, not MCP)
|
||||
#[arg(long)]
|
||||
test_launch: bool,
|
||||
|
||||
|
||||
/// EA name for test launch
|
||||
#[arg(long)]
|
||||
ea: Option<String>,
|
||||
|
||||
|
||||
/// Startup delay for test launch (default: 10)
|
||||
#[arg(long)]
|
||||
startup_delay: Option<u64>,
|
||||
@@ -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<String>, startup_delay: Option<u64>) -> 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<String>, startup_delay: Option<u64>) -> 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<String>, startup_delay: Option<u64>) -> 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::<mcp_server::Notification>();
|
||||
let (notification_tx, mut notification_rx) =
|
||||
tokio::sync::mpsc::unbounded_channel::<mcp_server::Notification>();
|
||||
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::<McpRequest>(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::<McpRequest>(line) {
|
||||
Ok(request) => {
|
||||
if request.id.is_none() {
|
||||
@@ -304,6 +305,6 @@ async fn handle_connection(socket: tokio::net::TcpStream) -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+58
-60
@@ -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<dyn Fn(&str, serde_json::Value) + Send + Sync>;
|
||||
@@ -43,7 +45,7 @@ impl McpServer {
|
||||
pub async fn set_notification_sender(&self, tx: mpsc::UnboundedSender<Notification>) {
|
||||
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<bool>, 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
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+153
-135
@@ -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<String>,
|
||||
pub terminal_dir: Option<String>,
|
||||
@@ -102,36 +108,6 @@ pub struct Config {
|
||||
pub project_dir: Option<String>,
|
||||
}
|
||||
|
||||
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<Self> {
|
||||
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<PathBuf> {
|
||||
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<CurrentAccount> {
|
||||
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
|
||||
|
||||
+39
-14
@@ -18,31 +18,56 @@ impl Metrics {
|
||||
let mut m = Metrics::default();
|
||||
|
||||
let patterns = [
|
||||
("net_profit", r"Net\s+Profit[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
("profit_factor", r"Profit\s+Factor[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
("max_dd_pct", r"Equity\s+Drawdown\s+Maximal[^<]*</td>\s*<td[^>]*>\s*<b>[^(]*\(([\d.,]+)%\)"),
|
||||
("sharpe_ratio", r"Sharpe\s+Ratio[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
("total_trades", r"Total\s+Trades[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
("recovery_factor", r"Recovery\s+Factor[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
("win_rate_pct", r"Profit\s+Trades\s+\(%[^<]*</td>\s*<td[^>]*>\s*<b>[^(]*\(([\d.,]+)%\)"),
|
||||
("gross_profit", r"Gross\s+Profit[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
("gross_loss", r"Gross\s+Loss[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
(
|
||||
"net_profit",
|
||||
r"Net\s+Profit[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>",
|
||||
),
|
||||
(
|
||||
"profit_factor",
|
||||
r"Profit\s+Factor[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>",
|
||||
),
|
||||
(
|
||||
"max_dd_pct",
|
||||
r"Equity\s+Drawdown\s+Maximal[^<]*</td>\s*<td[^>]*>\s*<b>[^(]*\(([\d.,]+)%\)",
|
||||
),
|
||||
(
|
||||
"sharpe_ratio",
|
||||
r"Sharpe\s+Ratio[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>",
|
||||
),
|
||||
(
|
||||
"total_trades",
|
||||
r"Total\s+Trades[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>",
|
||||
),
|
||||
(
|
||||
"recovery_factor",
|
||||
r"Recovery\s+Factor[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>",
|
||||
),
|
||||
(
|
||||
"win_rate_pct",
|
||||
r"Profit\s+Trades\s+\(%[^<]*</td>\s*<td[^>]*>\s*<b>[^(]*\(([\d.,]+)%\)",
|
||||
),
|
||||
(
|
||||
"gross_profit",
|
||||
r"Gross\s+Profit[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>",
|
||||
),
|
||||
(
|
||||
"gross_loss",
|
||||
r"Gross\s+Loss[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>",
|
||||
),
|
||||
];
|
||||
|
||||
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),
|
||||
|
||||
+122
-65
@@ -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<String> {
|
||||
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<String> {
|
||||
.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<u16> = vec![0xFEFF]; // BOM
|
||||
utf16_content.extend(content.encode_utf16());
|
||||
|
||||
let bytes: Vec<u8> = utf16_content.iter()
|
||||
|
||||
let bytes: Vec<u8> = utf16_content
|
||||
.iter()
|
||||
.flat_map(|&c| vec![(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8])
|
||||
.collect();
|
||||
|
||||
|
||||
fs::write(dst, bytes)?;
|
||||
|
||||
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::<serde_json::Value>(&content) {
|
||||
let job_id = path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let pid = meta.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
let is_running = self.is_process_running(pid);
|
||||
|
||||
jobs.push(serde_json::json!({
|
||||
"job_id": job_id,
|
||||
"expert": meta.get("expert"),
|
||||
"status": if is_running { "running" } else { "stopped" },
|
||||
"started_at": meta.get("started_at"),
|
||||
}));
|
||||
}
|
||||
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::<serde_json::Value>(&content) {
|
||||
let job_id = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let pid = meta.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
let is_running = self.is_process_running(pid);
|
||||
|
||||
jobs.push(serde_json::json!({
|
||||
"job_id": job_id,
|
||||
"expert": meta.get("expert"),
|
||||
"status": if is_running { "running" } else { "stopped" },
|
||||
"started_at": meta.get("started_at"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+129
-66
@@ -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"<?xml") || header.windows(8).any(|w| w == b"Workbook") {
|
||||
if header.windows(5).any(|w| w == b"<?xml")
|
||||
|| header.windows(8).any(|w| w == b"Workbook")
|
||||
{
|
||||
return "xml";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
"html"
|
||||
}
|
||||
|
||||
fn read_text(&self, path: &Path) -> Result<String> {
|
||||
let raw = fs::read(path)?;
|
||||
|
||||
|
||||
// Try UTF-16 first (common for MT5 reports)
|
||||
if raw.len() >= 2 {
|
||||
if raw[0] == 0xFF && raw[1] == 0xFE {
|
||||
// UTF-16 LE with BOM
|
||||
let u16_vec: Vec<u16> = raw[2..].chunks_exact(2)
|
||||
let u16_vec: Vec<u16> = raw[2..]
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
return Ok(String::from_utf16_lossy(&u16_vec));
|
||||
} else if raw[0] == 0xFE && raw[1] == 0xFF {
|
||||
// UTF-16 BE with BOM
|
||||
let u16_vec: Vec<u16> = raw[2..].chunks_exact(2)
|
||||
let u16_vec: Vec<u16> = raw[2..]
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
return Ok(String::from_utf16_lossy(&u16_vec));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Try UTF-8, then fallback to lossy
|
||||
if let Ok(text) = String::from_utf8(raw.clone()) {
|
||||
return Ok(text);
|
||||
}
|
||||
|
||||
|
||||
// Try UTF-16 without BOM
|
||||
if raw.len() % 2 == 0 {
|
||||
let u16_vec: Vec<u16> = raw.chunks_exact(2)
|
||||
let u16_vec: Vec<u16> = raw
|
||||
.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
let text = String::from_utf16_lossy(&u16_vec);
|
||||
@@ -130,95 +136,112 @@ impl OptimizationParser {
|
||||
return Ok(text);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(String::from_utf8_lossy(&raw).to_string())
|
||||
}
|
||||
|
||||
fn parse_html(&self, text: &str) -> Result<Vec<OptimizationPass>> {
|
||||
let mut results = Vec::new();
|
||||
let mut headers: Vec<String> = Vec::new();
|
||||
|
||||
|
||||
// Find all table rows
|
||||
let row_regex = regex::Regex::new(r"<tr[^>]*>(.*?)</tr>")?;
|
||||
let cell_regex = regex::Regex::new(r"<t[dh][^>]*>(.*?)</t[dh]>")?;
|
||||
let tag_regex = regex::Regex::new(r"<[^>]+>")?;
|
||||
|
||||
|
||||
for row_caps in row_regex.captures_iter(text) {
|
||||
let row = &row_caps[1];
|
||||
let cells: Vec<String> = cell_regex.captures_iter(row)
|
||||
let cells: Vec<String> = 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::<u32>().is_ok() {
|
||||
let row_map: HashMap<String, String> = headers.iter()
|
||||
let row_map: HashMap<String, String> = headers
|
||||
.iter()
|
||||
.zip(cells.iter())
|
||||
.map(|(h, c)| (h.to_lowercase().replace(' ', "_"), c.clone()))
|
||||
.collect();
|
||||
|
||||
|
||||
if let Some(pass) = self.row_to_pass(&row_map) {
|
||||
results.push(pass);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
fn parse_xml(&self, text: &str) -> Result<Vec<OptimizationPass>> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
|
||||
// Parse SpreadsheetML XML
|
||||
let doc = roxmltree::Document::parse(text)?;
|
||||
|
||||
|
||||
// Find all rows in Worksheet/Table
|
||||
for node in doc.descendants() {
|
||||
if node.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Row")) ||
|
||||
node.has_tag_name("Row") {
|
||||
let cells: Vec<String> = node.descendants()
|
||||
if node.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Row"))
|
||||
|| node.has_tag_name("Row")
|
||||
{
|
||||
let cells: Vec<String> = 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::<u32>() {
|
||||
if pass_num > 0 {
|
||||
let mut row_map = HashMap::new();
|
||||
|
||||
|
||||
// Standard MT5 optimization report columns
|
||||
let headers = vec![
|
||||
"pass", "result", "profit", "total_trades", "profit_factor",
|
||||
"expected_payoff", "drawdown_pct", "recovery_factor", "sharpe_ratio",
|
||||
"custom", "consecutive_wins", "consecutive_losses",
|
||||
"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<String, String>) -> Option<OptimizationPass> {
|
||||
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<String, String> = row.iter()
|
||||
.filter(|(k, _)| ![
|
||||
"pass", "result", "profit", "total_trades", "profit_factor",
|
||||
"expected_payoff", "drawdown_pct", "max_drawdown", "recovery_factor",
|
||||
"sharpe_ratio", "custom", "consecutive_wins", "consecutive_losses"
|
||||
].contains(&k.as_str()))
|
||||
let params: HashMap<String, String> = row
|
||||
.iter()
|
||||
.filter(|(k, _)| {
|
||||
![
|
||||
"pass",
|
||||
"result",
|
||||
"profit",
|
||||
"total_trades",
|
||||
"profit_factor",
|
||||
"expected_payoff",
|
||||
"drawdown_pct",
|
||||
"max_drawdown",
|
||||
"recovery_factor",
|
||||
"sharpe_ratio",
|
||||
"custom",
|
||||
"consecutive_wins",
|
||||
"consecutive_losses",
|
||||
]
|
||||
.contains(&k.as_str())
|
||||
})
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
|
||||
|
||||
Some(OptimizationPass {
|
||||
pass,
|
||||
profit,
|
||||
@@ -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::<f64>().ok()).unwrap_or(0.0);
|
||||
let b_sharpe = b.params.get("sharpe_ratio").and_then(|v| v.parse::<f64>().ok()).unwrap_or(0.0);
|
||||
let a_sharpe = a
|
||||
.params
|
||||
.get("sharpe_ratio")
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
let b_sharpe = b
|
||||
.params
|
||||
.get("sharpe_ratio")
|
||||
.and_then(|v| v.parse::<f64>().ok())
|
||||
.unwrap_or(0.0);
|
||||
a_sharpe.partial_cmp(&b_sharpe).unwrap()
|
||||
}),
|
||||
"drawdown" => passes.iter().min_by(|a, b| a.drawdown_pct.partial_cmp(&b.drawdown_pct).unwrap()),
|
||||
_ => passes.iter().max_by(|a, b| a.profit.partial_cmp(&b.profit).unwrap()),
|
||||
"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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+541
-219
File diff suppressed because it is too large
Load Diff
+30
-18
@@ -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<Vec<(String, String, Option<String>)>> {
|
||||
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::<Vec<_>>()
|
||||
.join(" OR ");
|
||||
@@ -587,9 +589,8 @@ impl ReportDb {
|
||||
placeholders,
|
||||
tags.len() + 1
|
||||
);
|
||||
let mut p: Vec<rusqlite::types::Value> = tags.iter()
|
||||
.map(|t| format!("%{}%", t).into())
|
||||
.collect();
|
||||
let mut p: Vec<rusqlite::types::Value> =
|
||||
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<ReportEntry> = 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<ReportEntry> = 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<ReportEntry> = stmt
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
+156
-55
@@ -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<Deal>, 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<Deal>, Metrics, DealAnalyzer, S
|
||||
pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let (deals, metrics, analyzer, report_dir) = prepare_analysis(args)?;
|
||||
|
||||
let requested: Option<HashSet<String>> = 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<HashSet<String>> =
|
||||
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<V
|
||||
|
||||
pub async fn handle_analyze_monthly_pnl(_config: &Config, args: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
pub async fn handle_analyze_hold_time_distribution(
|
||||
_config: &Config,
|
||||
args: &Value,
|
||||
) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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("");
|
||||
}
|
||||
|
||||
+335
-140
@@ -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<Value> {
|
||||
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<Value>
|
||||
"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<Value>
|
||||
// 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<Value>
|
||||
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<Value>
|
||||
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<Value>
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_run_backtest_quick(handler: &crate::tools::handlers::ToolHandler, args: &Value) -> Result<Value> {
|
||||
pub async fn handle_run_backtest_quick(
|
||||
handler: &crate::tools::handlers::ToolHandler,
|
||||
args: &Value,
|
||||
) -> Result<Value> {
|
||||
// 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<Value> {
|
||||
pub async fn handle_run_backtest_only(
|
||||
handler: &crate::tools::handlers::ToolHandler,
|
||||
args: &Value,
|
||||
) -> Result<Value> {
|
||||
// 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<Value> {
|
||||
let expert = args.get("expert")
|
||||
pub async fn handle_launch_backtest(
|
||||
handler: &crate::tools::handlers::ToolHandler,
|
||||
args: &Value,
|
||||
) -> Result<Value> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>()
|
||||
}))?)?;
|
||||
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::<Vec<_>>()
|
||||
}))?,
|
||||
)?;
|
||||
|
||||
// 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::<BacktestJob>(&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::<serde_json::Value>(&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<Value> {
|
||||
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<BacktestJob> = 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<Value> {
|
||||
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<Valu
|
||||
let (deals, final_balance_pips, progress) = BacktestPipeline::parse_journal_deals(&lines);
|
||||
|
||||
// Collect tail lines
|
||||
let tail: Vec<&str> = 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<Valu
|
||||
.collect();
|
||||
|
||||
// Detect last sim timestamp for progress estimation
|
||||
let last_sim_time = lines.iter().rev()
|
||||
let last_sim_time = lines
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|l| {
|
||||
let parts: Vec<&str> = 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<Valu
|
||||
}
|
||||
|
||||
pub async fn handle_cache_status(config: &Config) -> Result<Value> {
|
||||
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<Value> {
|
||||
|
||||
pub async fn handle_clean_cache(config: &Config, args: &Value) -> Result<Value> {
|
||||
let _symbol = args.get("symbol").and_then(|v| v.as_str());
|
||||
let dry_run = args.get("dry_run").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Value> {
|
||||
fn scan_mql_dir(
|
||||
dir: Option<&String>,
|
||||
filter: Option<&str>,
|
||||
type_label: Option<&str>,
|
||||
) -> Vec<Value> {
|
||||
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<Value> {
|
||||
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<Value>
|
||||
}
|
||||
|
||||
pub async fn handle_search_experts(config: &Config, args: &Value) -> Result<Value> {
|
||||
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<Valu
|
||||
|
||||
pub async fn handle_list_indicators(config: &Config, args: &Value) -> Result<Value> {
|
||||
let filter = args.get("filter").and_then(|v| v.as_str());
|
||||
let include_builtin = args.get("include_builtin").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let 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<Value>
|
||||
}
|
||||
|
||||
pub async fn handle_search_indicators(config: &Config, args: &Value) -> Result<Value> {
|
||||
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<V
|
||||
}
|
||||
|
||||
pub async fn handle_search_scripts(config: &Config, args: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
} 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<Value> {
|
||||
)),
|
||||
}
|
||||
} 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<Value> {
|
||||
"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(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
+104
-52
@@ -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<dyn Fn(&str, serde_json::Value) + Send + Sync>;
|
||||
|
||||
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<Value> {
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Value> {
|
||||
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<Va
|
||||
}
|
||||
|
||||
pub async fn handle_get_optimization_status(config: &Config, args: &Value) -> Result<Value> {
|
||||
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::<serde_json::Value>(&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<Value> {
|
||||
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);
|
||||
|
||||
+418
-264
@@ -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<Value> {
|
||||
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<Value> {
|
||||
|
||||
let reports: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"charts_dir": e.charts_dir,
|
||||
"report_dir": e.report_dir,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
"notes": e.notes,
|
||||
}))
|
||||
.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<Value> {
|
||||
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<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"set_snapshot": e.set_snapshot_path,
|
||||
"charts_dir": e.charts_dir,
|
||||
"report_dir": e.report_dir,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
"notes": e.notes,
|
||||
}))
|
||||
.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<Value> {
|
||||
|
||||
pub async fn handle_prune_reports(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let keep_last = args.get("keep_last").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
|
||||
let dry_run = args.get("dry_run").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let 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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
|
||||
pub async fn handle_archive_report(_config: &Config, args: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
|
||||
let history: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"set_file": e.set_file_original,
|
||||
"set_snapshot": e.set_snapshot_path,
|
||||
"charts_dir": e.charts_dir,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
"notes": e.notes,
|
||||
}))
|
||||
.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<Value> {
|
||||
|
||||
let notes = args.get("notes").and_then(|v| v.as_str());
|
||||
let verdict = args.get("verdict").and_then(|v| v.as_str());
|
||||
let tags: Option<Vec<String>> = args
|
||||
.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect());
|
||||
let tags: Option<Vec<String>> = args.get("tags").and_then(|v| v.as_array()).map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect()
|
||||
});
|
||||
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
db.init()?;
|
||||
@@ -436,10 +503,14 @@ pub async fn handle_annotate_history(args: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub async fn handle_get_report_by_id(_config: &Config, args: &Value) -> Result<Value> {
|
||||
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<V
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,15 +575,13 @@ pub async fn handle_get_report_by_id(_config: &Config, args: &Value) -> Result<V
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
db.init()?;
|
||||
|
||||
let sort_by = args.get("sort_by").and_then(|v| v.as_str()).unwrap_or("profit_factor");
|
||||
let 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<Value> {
|
||||
|
||||
let reports: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"sharpe_ratio": e.sharpe_ratio,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
}))
|
||||
.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<Value> {
|
||||
let tags: Vec<String> = args
|
||||
.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.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<Value> {
|
||||
|
||||
let reports: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
"notes": e.notes,
|
||||
}))
|
||||
.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<Value>
|
||||
|
||||
let reports: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
}))
|
||||
.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<Value>
|
||||
}
|
||||
|
||||
pub async fn handle_search_reports_by_notes(args: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
|
||||
let reports: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"verdict": e.verdict,
|
||||
"notes": e.notes,
|
||||
"tags": e.tags,
|
||||
}))
|
||||
.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<Value> {
|
||||
}
|
||||
|
||||
pub async fn handle_get_reports_by_set_file(args: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
|
||||
let reports: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file_original": e.set_file_original,
|
||||
"set_snapshot_path": e.set_snapshot_path,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
}))
|
||||
.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<Value> {
|
||||
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<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
}))
|
||||
.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<Value> {
|
||||
pub async fn handle_export_deals_csv(_config: &Config, args: &Value) -> Result<Value> {
|
||||
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<V
|
||||
|
||||
let entry = match entry {
|
||||
Some(e) => 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<V
|
||||
};
|
||||
|
||||
let extractor = ReportExtractor::new();
|
||||
extractor.write_deals_to_csv(&deals, &output_path)
|
||||
extractor
|
||||
.write_deals_to_csv(&deals, &output_path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write CSV: {}", e))?;
|
||||
|
||||
Ok(json!({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::models::Config;
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use crate::models::Config;
|
||||
|
||||
/// Read a file that may be UTF-16LE (with BOM) or UTF-8, returning a UTF-8 String.
|
||||
fn read_file_as_utf8(path: &str) -> Result<String> {
|
||||
@@ -14,13 +14,13 @@ fn read_file_as_utf8(path: &str) -> Result<String> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
|
||||
pub async fn handle_write_set_file(args: &Value) -> Result<Value> {
|
||||
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<u16> = vec![0xFEFF]; // BOM - matches the format MT5's tester actually writes/expects
|
||||
utf16_content.extend(lines.join("\r\n").encode_utf16());
|
||||
let bytes: Vec<u8> = 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<Value> {
|
||||
}
|
||||
|
||||
pub async fn handle_patch_set_file(args: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
} 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<Value> {
|
||||
}
|
||||
|
||||
pub async fn handle_clone_set_file(args: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
|
||||
pub async fn handle_diff_set_files(args: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
|
||||
pub async fn handle_set_from_optimization(args: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
|
||||
pub async fn handle_describe_sweep(args: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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();
|
||||
|
||||
+142
-70
@@ -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<String> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
// 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<Value> {
|
||||
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<String>, is_dir: bool| -> Value {
|
||||
match v {
|
||||
None => json!({ "ok": false, "detail": "not set" }),
|
||||
Some(p) => {
|
||||
let ok = if is_dir { Path::new(p).is_dir() } else { Path::new(p).exists() };
|
||||
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<Value> {
|
||||
// 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<Value> {
|
||||
} 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<Value> {
|
||||
pub async fn handle_get_active_account(config: &Config) -> Result<Value> {
|
||||
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<Value> {
|
||||
} 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<Value> {
|
||||
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<Value>
|
||||
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<Value>
|
||||
},
|
||||
"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<Value>
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
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<Value>
|
||||
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<String> {
|
||||
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<String> {
|
||||
async fn validate_configuration(config: &Config) -> ConfigStatus {
|
||||
let config_path = Config::writable_config_path();
|
||||
let config_exists = config_path.exists();
|
||||
|
||||
let wine_found = config.wine_executable.as_ref()
|
||||
|
||||
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(),
|
||||
|
||||
+511
-345
File diff suppressed because it is too large
Load Diff
@@ -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("<html") || content.contains("<table"),
|
||||
"Report should contain HTML or table elements");
|
||||
assert!(
|
||||
content.contains("<html") || content.contains("<table"),
|
||||
"Report should contain HTML or table elements"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -52,6 +57,8 @@ fn test_sample_report_xml_format() {
|
||||
let content = fs::read_to_string(path).expect("Should read sample_report.htm.xml");
|
||||
|
||||
// Check XML structure
|
||||
assert!(content.contains("<?xml") || content.contains("<Workbook"),
|
||||
"Report should contain XML or Workbook elements");
|
||||
assert!(
|
||||
content.contains("<?xml") || content.contains("<Workbook"),
|
||||
"Report should contain XML or Workbook elements"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user