2026-04-19 08:19:45 +07:00
use anyhow ::{ Context , Result };
2026-04-19 08:11:17 +07:00
use serde_json ::{ json , Value };
use std ::fs ;
2026-04-19 08:19:45 +07:00
use std ::path ::{ Path , PathBuf };
2026-04-19 08:11:17 +07:00
use crate ::models ::Config ;
2026-04-19 08:19:45 +07:00
/// Validate that `user_path` resolves to a location within `allowed_base`.
/// Returns the canonicalized absolute path on success.
fn safe_output_path ( user_path : & str , allowed_base : & Path ) -> Result < PathBuf > {
// Resolve the base first (must already exist).
let base = allowed_base . canonicalize ()
. with_context ( || format! ( "allowed base directory does not exist: {} " , allowed_base . display ())) ? ;
// Build the candidate path. If the user supplied an absolute path we use it
// as-is; relative paths are joined onto the base so they stay inside it.
let candidate = {
let p = Path ::new ( user_path );
if p . is_absolute () { p . to_path_buf () } else { base . join ( p ) }
};
// Canonicalize the parent directory (the file itself need not exist yet).
let parent = candidate . parent ()
. ok_or_else ( || anyhow ::anyhow! ( "output_path has no parent directory" )) ? ;
let canonical_parent = parent . canonicalize ()
. with_context ( || format! ( "output_path parent directory does not exist: {} " , parent . display ())) ? ;
let canonical = canonical_parent . join (
candidate . file_name ()
. ok_or_else ( || anyhow ::anyhow! ( "output_path must include a filename" )) ? ,
);
if ! canonical . starts_with ( & base ) {
return Err ( anyhow ::anyhow! (
"output_path '{}' is outside the allowed directory '{}'" ,
user_path , base . display ()
));
}
Ok ( canonical )
}
2026-04-19 08:11:17 +07:00
/// Check if symbol has sufficient data for date range
pub async fn handle_check_symbol_data_status ( config : & Config , args : & Value ) -> Result < Value > {
let symbol = args . get ( "symbol" )
. and_then ( | v | v . as_str ())
. ok_or_else ( || anyhow ::anyhow! ( "symbol is required" )) ? ;
let from_date = args . get ( "from_date" )
. and_then ( | v | v . as_str ())
. ok_or_else ( || anyhow ::anyhow! ( "from_date is required" )) ? ;
let to_date = args . get ( "to_date" )
. and_then ( | v | v . as_str ())
. ok_or_else ( || anyhow ::anyhow! ( "to_date is required" )) ? ;
// Get active account to determine which server to check
let current_account = config . current_account ();
let server = current_account . as_ref (). map ( | a | a . server . as_str ()). unwrap_or ( "" );
// Check if symbol exists in available symbols
let available_symbols = config . discover_symbols_for_active_account ();
let symbol_available = available_symbols . contains ( & symbol . to_string ());
if ! symbol_available {
return Ok ( json! ({
"content" : [{ "type" : "text" , "text" : json ! ({
"symbol" : symbol ,
"has_sufficient_data" : false ,
"error" : format ! ( "Symbol '{}' not available for server '{}'" , symbol , server ),
"available_symbols" : available_symbols ,
"suggestion" : "Use get_active_account to see available symbols for this account"
}). to_string () }],
"isError" : false
}));
}
// Try to find hcc files to determine actual data range
let mt5_dir = config . mt5_dir ();
let mut data_range_start = None ;
let mut data_range_end = None ;
let mut bars_count = 0 ;
if let Some ( mt5_path ) = mt5_dir {
let bases_dir = mt5_path . join ( "Bases" );
if bases_dir . exists () {
// Look through servers for this symbol
for server_entry in fs ::read_dir ( & bases_dir ) ? . flatten () {
let server_name = server_entry . file_name (). to_string_lossy (). to_string ();
if server . is_empty () || server_name == server {
let symbol_dir = server_entry . path (). join ( "history" ). join ( symbol );
if symbol_dir . exists () {
// Count hcc files and get date range
for entry in fs ::read_dir ( & symbol_dir ) ? . flatten () {
if let Some ( ext ) = entry . path (). extension () {
if ext == "hcc" {
bars_count += 1 ;
if let Some ( fname ) = entry . file_name (). to_str () {
// Parse year from filename (e.g., "2024.hcc")
if let Ok ( year ) = fname . trim_end_matches ( ".hcc" ). parse ::< i32 > () {
if data_range_start . is_none () || year < data_range_start . unwrap () {
data_range_start = Some ( year );
}
if data_range_end . is_none () || year > data_range_end . unwrap () {
data_range_end = Some ( year );
}
}
}
}
}
}
}
}
}
}
}
// Parse requested date range
let parse_date = | date_str : & str | -> Option < ( i32 , i32 , i32 ) > {
let parts : Vec <& str > = date_str . split ( '.' ). collect ();
if parts . len () == 3 {
if let ( Ok ( y ), Ok ( m ), Ok ( d )) = ( parts [ 0 ]. parse (), parts [ 1 ]. parse (), parts [ 2 ]. parse ()) {
return Some (( y , m , d ));
}
}
None
};
let req_from = parse_date ( from_date );
let req_to = parse_date ( to_date );
let mut has_sufficient = true ;
let mut warnings = Vec ::new ();
if let ( Some ( req_year ), Some ( start_year ), Some ( end_year )) = ( req_from . map ( | d | d . 0 ), data_range_start , data_range_end ) {
if req_year < start_year {
has_sufficient = false ;
warnings . push ( format! ( "Requested start year {} is before available data start {} " , req_year , start_year ));
}
if let Some ( req_to_year ) = req_to . map ( | d | d . 0 ) {
if req_to_year > end_year {
has_sufficient = false ;
warnings . push ( format! ( "Requested end year {} is after available data end {} " , req_to_year , end_year ));
}
}
}
Ok ( json! ({
"content" : [{ "type" : "text" , "text" : json ! ({
"symbol" : symbol ,
"server" : server ,
"has_sufficient_data" : has_sufficient && bars_count > 0 ,
"requested_range" : {
"from" : from_date ,
"to" : to_date
},
"data_range" : match ( data_range_start , data_range_end ) {
( Some ( s ), Some ( e )) => format! ( " {} .01.01 - {} .12.31" , s , e ),
_ => "unknown" . to_string ()
},
"years_available" : data_range_end . map ( | e | e - data_range_start . unwrap_or ( e ) + 1 ). unwrap_or ( 0 ),
"hcc_files_count" : bars_count ,
"warnings" : if warnings . is_empty () { None } else { Some ( warnings ) },
"suggestion" : if ! has_sufficient { "Consider downloading more history in MT5 or adjusting date range" } else { "Data range is sufficient for backtest" }
}). to_string () }],
"isError" : false
}))
}
/// Get backtest history for EA/symbol
pub async fn handle_get_backtest_history ( config : & Config , args : & Value ) -> Result < Value > {
let expert_filter = args . get ( "expert" ). and_then ( | v | v . as_str ());
let symbol_filter = args . get ( "symbol" ). and_then ( | v | v . as_str ());
let limit = args . get ( "limit" ). and_then ( | v | v . as_u64 ()). unwrap_or ( 10 ) as usize ;
let reports_dir = config . reports_dir ();
let mut history = Vec ::new ();
if reports_dir . exists () {
for entry in fs ::read_dir ( & reports_dir ) ? . flatten () {
let path = entry . path ();
if path . is_dir () {
// Try to read metrics.json
let metrics_path = path . join ( "metrics.json" );
if let Ok ( content ) = fs ::read_to_string ( & metrics_path ) {
if let Ok ( metrics ) = serde_json ::from_str ::< Value > ( & content ) {
let report_expert = metrics . get ( "expert" ). and_then ( | v | v . as_str ());
let report_symbol = metrics . get ( "symbol" ). and_then ( | v | v . as_str ());
// Apply filters
if let ( Some ( e ), Some ( filter )) = ( report_expert , expert_filter ) {
if ! e . contains ( filter ) {
continue ;
}
}
if let ( Some ( s ), Some ( filter )) = ( report_symbol , symbol_filter ) {
if s != filter {
continue ;
}
}
// Extract key metrics
let summary = json! ({
"report_dir" : path . file_name (). and_then ( | n | n . to_str ()). unwrap_or ( "" ),
"date" : metrics . get ( "backtest_date" ). and_then ( | v | v . as_str ()),
"expert" : report_expert ,
"symbol" : report_symbol ,
"period" : metrics . get ( "period" ). and_then ( | v | v . as_str ()),
"profit" : metrics . get ( "net_profit" ). and_then ( | v | v . as_f64 ()),
"profit_factor" : metrics . get ( "profit_factor" ). and_then ( | v | v . as_f64 ()),
"expected_payoff" : metrics . get ( "expected_payoff" ). and_then ( | v | v . as_f64 ()),
"drawdown_pct" : metrics . get ( "drawdown_pct" ). and_then ( | v | v . as_f64 ()),
"total_trades" : metrics . get ( "total_trades" ). and_then ( | v | v . as_u64 ()),
"win_rate" : metrics . get ( "win_rate" ). and_then ( | v | v . as_f64 ()),
});
history . push ( summary );
}
}
}
}
}
// Sort by date (newest first)
history . sort_by ( | a , b | {
let date_a = a . get ( "date" ). and_then ( | v | v . as_str ()). unwrap_or ( "" );
let date_b = b . get ( "date" ). and_then ( | v | v . as_str ()). unwrap_or ( "" );
date_b . cmp ( date_a )
});
// Apply limit
let total = history . len ();
let limited : Vec < Value > = history . into_iter (). take ( limit ). collect ();
Ok ( json! ({
"content" : [{ "type" : "text" , "text" : json ! ({
"success" : true ,
"count" : limited . len (),
"total" : total ,
"filters" : {
"expert" : expert_filter ,
"symbol" : symbol_filter
},
"history" : limited ,
"hint" : if limited . is_empty () { "No backtest history found. Run a backtest first with run_backtest." } else { "Use compare_backtests to analyze differences between results." }
}). to_string () }],
"isError" : false
}))
}
/// Compare multiple backtests
2026-04-19 08:19:45 +07:00
pub async fn handle_compare_backtests ( _config : & Config , args : & Value ) -> Result < Value > {
2026-04-19 08:11:17 +07:00
let report_dirs = args . get ( "report_dirs" )
. and_then ( | v | v . as_array ())
. ok_or_else ( || anyhow ::anyhow! ( "report_dirs array is required" )) ? ;
if report_dirs . len () < 2 {
return Ok ( json! ({
"content" : [{ "type" : "text" , "text" : json ! ({
"error" : "At least 2 report directories required for comparison"
}). to_string () }],
"isError" : true
}));
}
let mut comparisons = Vec ::new ();
let mut base_metrics : Option < Value > = None ;
for dir_value in report_dirs {
let dir = dir_value . as_str (). unwrap_or ( "" );
let path = Path ::new ( dir );
let metrics_path = path . join ( "metrics.json" );
if let Ok ( content ) = fs ::read_to_string ( & metrics_path ) {
if let Ok ( metrics ) = serde_json ::from_str ::< Value > ( & content ) {
if base_metrics . is_none () {
base_metrics = Some ( metrics . clone ());
}
let summary = json! ({
"report_dir" : dir ,
"expert" : metrics . get ( "expert" ). and_then ( | v | v . as_str ()),
"symbol" : metrics . get ( "symbol" ). and_then ( | v | v . as_str ()),
"net_profit" : metrics . get ( "net_profit" ). and_then ( | v | v . as_f64 ()),
"profit_factor" : metrics . get ( "profit_factor" ). and_then ( | v | v . as_f64 ()),
"drawdown_pct" : metrics . get ( "drawdown_pct" ). and_then ( | v | v . as_f64 ()),
"total_trades" : metrics . get ( "total_trades" ). and_then ( | v | v . as_u64 ()),
"win_rate" : metrics . get ( "win_rate" ). and_then ( | v | v . as_f64 ()),
"expected_payoff" : metrics . get ( "expected_payoff" ). and_then ( | v | v . as_f64 ()),
"recovery_factor" : metrics . get ( "recovery_factor" ). and_then ( | v | v . as_f64 ()),
"sharpe_ratio" : metrics . get ( "sharpe_ratio" ). and_then ( | v | v . as_f64 ()),
});
comparisons . push ( summary );
}
}
}
// Calculate differences if we have base
let mut analysis = Vec ::new ();
if let Some ( base ) = & base_metrics {
let base_profit = base . get ( "net_profit" ). and_then ( | v | v . as_f64 ()). unwrap_or ( 0.0 );
let base_dd = base . get ( "drawdown_pct" ). and_then ( | v | v . as_f64 ()). unwrap_or ( 0.0 );
let base_pf = base . get ( "profit_factor" ). and_then ( | v | v . as_f64 ()). unwrap_or ( 0.0 );
2026-04-19 08:19:45 +07:00
for ( _i , comp ) in comparisons . iter (). enumerate (). skip ( 1 ) {
2026-04-19 08:11:17 +07:00
let profit = comp . get ( "net_profit" ). and_then ( | v | v . as_f64 ()). unwrap_or ( 0.0 );
let dd = comp . get ( "drawdown_pct" ). and_then ( | v | v . as_f64 ()). unwrap_or ( 0.0 );
let pf = comp . get ( "profit_factor" ). and_then ( | v | v . as_f64 ()). unwrap_or ( 0.0 );
analysis . push ( json! ({
"compare_to" : comparisons [ 0 ]. get ( "report_dir" ),
"report" : comp . get ( "report_dir" ),
"profit_diff" : profit - base_profit ,
"profit_pct_change" : if base_profit != 0.0 { (( profit - base_profit ) / base_profit . abs ()) * 100.0 } else { 0.0 },
"drawdown_diff" : dd - base_dd ,
"profit_factor_diff" : pf - base_pf ,
"verdict" : if profit > base_profit && dd <= base_dd { "better" }
else if profit < base_profit { "worse" }
else { "mixed" }
}));
}
}
Ok ( json! ({
"content" : [{ "type" : "text" , "text" : json ! ({
"success" : true ,
"count" : comparisons . len (),
"comparisons" : comparisons ,
"analysis" : if analysis . is_empty () { None } else { Some ( analysis ) },
"verdict" : if comparisons . len () >= 2 {
let profits : Vec < f64 > = comparisons . iter ()
. filter_map ( | c | c . get ( "net_profit" ). and_then ( | v | v . as_f64 ()))
. collect ();
let best_idx = profits . iter (). enumerate ()
. max_by ( | a , b | a . 1. partial_cmp ( b . 1 ). unwrap_or ( std ::cmp ::Ordering ::Equal ))
. map ( | ( i , _ ) | i );
best_idx . map ( | i | format! ( "Best: {} " ,
comparisons . get ( i ). and_then ( | c | c . get ( "report_dir" ). and_then ( | v | v . as_str ())). unwrap_or ( "unknown" )))
} else { None }
}). to_string () }],
"isError" : false
}))
}
/// Initialize new MQL5 project
pub async fn handle_init_project ( config : & Config , args : & Value ) -> Result < Value > {
let name = args . get ( "name" )
. and_then ( | v | v . as_str ())
. ok_or_else ( || anyhow ::anyhow! ( "name is required" )) ? ;
let template = args . get ( "template" ). and_then ( | v | v . as_str ()). unwrap_or ( "basic" );
// Determine project directory
let project_dir = config . project_dir . as_ref ()
. map ( | p | Path ::new ( p ). to_path_buf ())
. or_else ( || std ::env ::current_dir (). ok ())
. ok_or_else ( || anyhow ::anyhow! ( "Cannot determine project directory" )) ? ;
let ea_path = project_dir . join ( format! ( " {} .mq5" , name ));
// Basic EA template
let ea_template = match template {
"scalper" => format! ( r #"//+------------------------------------------------------------------+
//| {}.mq5 |
//| Scalper EA Template |
//+------------------------------------------------------------------+
#property copyright "Your Name"
#property link ""
#property version "1.00"
#property strict
input double Lots = 0.1;
input int StopLoss = 20; // points
input int TakeProfit = 10; // points
input int Slippage = 3;
int OnInit() {{
Print("{} Scalper EA initialized");
return(INIT_SUCCEEDED);
}}
void OnDeinit(const int reason) {{
Print("{} EA stopped, reason: ", reason);
}}
void OnTick() {{
static datetime last_bar = 0;
datetime current_bar = iTime(_Symbol, PERIOD_CURRENT, 0);
if(current_bar == last_bar) return; // New bar only
last_bar = current_bar;
// Fast MAs crossover logic here
double ma_fast = iMA(_Symbol, PERIOD_CURRENT, 5, 0, MODE_EMA, PRICE_CLOSE, 0);
double ma_slow = iMA(_Symbol, PERIOD_CURRENT, 15, 0, MODE_EMA, PRICE_CLOSE, 0);
if(PositionsTotal() == 0) {{
if(ma_fast > ma_slow) {{
// Buy signal
// OrderSend(_Symbol, ORDER_TYPE_BUY, Lots, Ask, Slippage, Bid-StopLoss*_Point, Bid+TakeProfit*_Point);
}}
else if(ma_fast < ma_slow) {{
// Sell signal
// OrderSend(_Symbol, ORDER_TYPE_SELL, Lots, Bid, Slippage, Ask+StopLoss*_Point, Ask-TakeProfit*_Point);
}}
}}
}}
"# , name , name , name ),
"swing" => format! ( r #"//+------------------------------------------------------------------+
//| {}.mq5 |
//| Swing EA Template |
//+------------------------------------------------------------------+
#property copyright "Your Name"
#property link ""
#property version "1.00"
#property strict
input double Lots = 0.1;
input int StopLoss = 100; // points
input int TakeProfit = 200; // points
input int TrailingStop = 50; // points
int OnInit() {{
Print("{} Swing EA initialized");
return(INIT_SUCCEEDED);
}}
void OnDeinit(const int reason) {{
Print("{} EA stopped");
}}
void OnTick() {{
// Daily/H4 trend following logic
double trend_ma = iMA(_Symbol, PERIOD_D1, 50, 0, MODE_SMA, PRICE_CLOSE, 0);
double price = iClose(_Symbol, PERIOD_CURRENT, 0);
// Implementation here
}}
"# , name , name , name ),
"grid" => format! ( r #"//+------------------------------------------------------------------+
//| {}.mq5 |
//| Grid EA Template |
//+------------------------------------------------------------------+
#property copyright "Your Name"
#property link ""
#property version "1.00"
#property strict
input double StartLots = 0.01;
input double LotMultiplier = 1.5;
input int GridPips = 20;
input int MaxLevels = 10;
int OnInit() {{
Print("{} Grid EA initialized - Use with extreme caution!");
return(INIT_SUCCEEDED);
}}
void OnDeinit(const int reason) {{
Print("{} Grid EA stopped");
}}
void OnTick() {{
// Grid trading logic
// WARNING: Grid strategies can lead to significant losses
}}
"# , name , name , name ),
_ => format! ( r #"//+------------------------------------------------------------------+
//| {}.mq5 |
//| Basic EA Template |
//+------------------------------------------------------------------+
#property copyright "Your Name"
#property link ""
#property version "1.00"
#property strict
input double Lots = 0.1;
input int StopLoss = 50;
input int TakeProfit = 50;
int OnInit() {{
Print("{} EA initialized");
return(INIT_SUCCEEDED);
}}
void OnDeinit(const int reason) {{
Print("{} EA stopped");
}}
void OnTick() {{
// Your trading logic here
// Check for open positions, signals, etc.
}}
"# , name , name , name )
};
// Write EA file
fs ::write ( & ea_path , ea_template ) ? ;
// Create README
let readme_path = project_dir . join ( "README.md" );
let readme_content = format! ( r #"# {}
MQL5 Expert Advisor generated by MT5-Quant
## Files
- `{}`.mq5 - Main EA source code
## Parameters
Edit the `input` variables in the source code to customize:
- `Lots` - Trading lot size
- `StopLoss` - Stop loss in points
- `TakeProfit` - Take profit in points
## Build & Test
```bash
# Compile EA
mt5-quant compile_ea expert={}
# Run backtest
mt5-quant run_backtest expert={} symbol=XAUUSDc
```
"# , name , name , name , name );
fs ::write ( & readme_path , readme_content ) ? ;
Ok ( json! ({
"content" : [{ "type" : "text" , "text" : json ! ({
"success" : true ,
"project_name" : name ,
"template" : template ,
"created_files" : [
ea_path . to_string_lossy (). to_string (),
readme_path . to_string_lossy (). to_string ()
],
"hint" : format ! ( "Edit {}.mq5 to implement your strategy, then compile with compile_ea" , name )
}). to_string () }],
"isError" : false
}))
}
/// Validate EA syntax (basic check without full compile)
pub async fn handle_validate_ea_syntax ( _config : & Config , args : & Value ) -> Result < Value > {
let path = args . get ( "path" )
. and_then ( | v | v . as_str ())
. ok_or_else ( || anyhow ::anyhow! ( "path is required" )) ? ;
let content = fs ::read_to_string ( path ) ? ;
let mut errors = Vec ::new ();
let mut warnings = Vec ::new ();
// Basic syntax checks
let lines : Vec <& str > = content . lines (). collect ();
for ( i , line ) in lines . iter (). enumerate () {
let line_num = i + 1 ;
let trimmed = line . trim ();
// Check for basic issues
if trimmed . ends_with ( '{' ) && ! trimmed . contains ( '}' ) {
// This is just a heuristic, not a real syntax error
}
if trimmed . contains ( "OrderSend" ) && ! trimmed . starts_with ( "//" ) {
warnings . push ( json! ({
"line" : line_num ,
"message" : "OrderSend found - ensure proper error handling" ,
"severity" : "warning"
}));
}
if trimmed . contains ( "input" ) && trimmed . contains ( "double" ) && trimmed . contains ( "Lots" ) {
if ! trimmed . contains ( "=" ) {
warnings . push ( json! ({
"line" : line_num ,
"message" : "Input parameter 'Lots' has no default value" ,
"severity" : "warning"
}));
}
}
}
// Check for required sections
let has_on_init = content . contains ( "int OnInit()" );
let has_on_tick = content . contains ( "void OnTick()" );
let has_on_deinit = content . contains ( "void OnDeinit" );
if ! has_on_init {
errors . push ( json! ({
"line" : 0 ,
"message" : "Missing OnInit() function - required for EA" ,
"severity" : "error"
}));
}
if ! has_on_tick && ! content . contains ( "void OnTimer()" ) {
warnings . push ( json! ({
"line" : 0 ,
"message" : "No OnTick() or OnTimer() found - EA won't respond to events" ,
"severity" : "warning"
}));
}
let valid = errors . is_empty ();
Ok ( json! ({
"content" : [{ "type" : "text" , "text" : json ! ({
"success" : valid ,
"valid" : valid ,
"path" : path ,
"checks" : {
"has_on_init" : has_on_init ,
"has_on_tick" : has_on_tick ,
"has_on_deinit" : has_on_deinit ,
"lines" : lines . len ()
},
"errors" : if errors . is_empty () { None } else { Some ( errors ) },
"warnings" : if warnings . is_empty () { None } else { Some ( warnings ) },
"hint" : if valid { "Syntax looks good. Run compile_ea for full compilation check." } else { "Fix errors before compiling." }
}). to_string () }],
"isError" : ! valid
}))
}
/// Check MT5 terminal status
pub async fn handle_check_mt5_status ( config : & Config ) -> Result < Value > {
let mt5_dir = config . mt5_dir ();
let wine_exe = config . wine_executable . as_ref ();
// Check if MT5 files exist
let terminal_exists = mt5_dir . as_ref (). map ( | d | d . join ( "terminal64.exe" ). exists ()). unwrap_or ( false );
let metaeditor_exists = mt5_dir . as_ref (). map ( | d | d . join ( "metaeditor64.exe" ). exists ()). unwrap_or ( false );
let tester_exists = mt5_dir . as_ref (). map ( | d | d . join ( "metatester64.exe" ). exists ()). unwrap_or ( false );
// Check Wine
let wine_ok = wine_exe . map ( | w | Path ::new ( w ). exists ()). unwrap_or ( false );
// Try to get MT5 version (would need to actually run it, skip for now)
let mut mt5_version = None ;
if wine_ok && terminal_exists {
// Could run: wine terminal64.exe /version but it's complex
mt5_version = Some ( "detected" . to_string ());
}
let all_ok = terminal_exists && metaeditor_exists && tester_exists && wine_ok ;
Ok ( json! ({
"content" : [{ "type" : "text" , "text" : json ! ({
"success" : all_ok ,
"terminal_ready" : all_ok ,
"checks" : {
"mt5_dir_exists" : mt5_dir . as_ref (). map ( | d | d . exists ()). unwrap_or ( false ),
"terminal64_exe" : terminal_exists ,
"metaeditor64_exe" : metaeditor_exists ,
"metatester64_exe" : tester_exists ,
"wine_executable" : wine_ok ,
"wine_path" : wine_exe
},
"mt5_version" : mt5_version ,
"current_account" : config . current_account (). map ( | a | json! ({
"login" : a . login ,
"server" : a . server
})),
"hint" : if all_ok {
"MT5 is ready for backtesting and optimization."
} else {
"Some components missing. Run verify_setup for detailed diagnostics."
}
}). to_string () }],
"isError" : false
}))
}
/// Create .set file template from EA
pub async fn handle_create_set_template ( config : & Config , args : & Value ) -> Result < Value > {
let ea = args . get ( "ea" )
. and_then ( | v | v . as_str ())
. ok_or_else ( || anyhow ::anyhow! ( "ea is required" )) ? ;
let output_path = args . get ( "output_path" ). and_then ( | v | v . as_str ());
// Find EA source file
let ea_path = if Path ::new ( ea ). exists () {
Path ::new ( ea ). to_path_buf ()
} else if let Some ( experts_dir ) = & config . experts_dir {
Path ::new ( experts_dir ). join ( format! ( " {} .mq5" , ea ))
} else {
return Err ( anyhow ::anyhow! ( "Cannot find EA: {}" , ea ));
};
if ! ea_path . exists () {
return Err ( anyhow ::anyhow! ( "EA file not found: {}" , ea_path . display ()));
}
let content = fs ::read_to_string ( & ea_path ) ? ;
let mut inputs = Vec ::new ();
// Parse input declarations
for line in content . lines () {
let trimmed = line . trim ();
if trimmed . starts_with ( "input " ) {
// Parse: input type name = default; // comment
let without_input = & trimmed [ 6 .. ]; // Remove "input "
// Extract comment if any
let parts : Vec <& str > = without_input . split ( "//" ). collect ();
let decl = parts [ 0 ]. trim ();
let comment = parts . get ( 1 ). map ( | c | c . trim ());
// Parse type and name
let tokens : Vec <& str > = decl . split_whitespace (). collect ();
if tokens . len () >= 2 {
let type_name = tokens [ 0 ];
let rest = tokens [ 1 .. ]. to_vec (). join ( " " );
// Parse name = value
let name_value : Vec <& str > = rest . split ( '=' ). collect ();
let name = name_value [ 0 ]. trim ();
let default_val = name_value . get ( 1 ). map ( | v | v . trim (). trim_end_matches ( ';' )). unwrap_or ( "0" );
inputs . push ( json! ({
"name" : name ,
"type" : type_name ,
"default" : default_val ,
"description" : comment
}));
}
}
}
// Generate .set content
let mut set_content = format! ( "; {} parameters generated by MT5-Quant \n " , ea );
set_content . push_str ( "; Format: name=value \n\n " );
for input in & inputs {
let name = input . get ( "name" ). and_then ( | v | v . as_str ()). unwrap_or ( "" );
let default = input . get ( "default" ). and_then ( | v | v . as_str ()). unwrap_or ( "0" );
let desc = input . get ( "description" ). and_then ( | v | v . as_str ()). unwrap_or ( "" );
if ! desc . is_empty () {
set_content . push_str ( & format! ( "; {} \n " , desc ));
}
set_content . push_str ( & format! ( " {} = {} \n\n " , name , default ));
}
// Determine output path
let set_path = if let Some ( path ) = output_path {
2026-04-19 08:19:45 +07:00
// Restrict writes to the tester profiles dir (or the EA's own dir as fallback).
let allowed_base = if let Some ( profiles_dir ) = & config . tester_profiles_dir {
Path ::new ( profiles_dir ). to_path_buf ()
} else {
ea_path . parent (). unwrap_or ( Path ::new ( "." )). to_path_buf ()
};
safe_output_path ( path , & allowed_base ) ?
2026-04-19 08:11:17 +07:00
} else if let Some ( profiles_dir ) = & config . tester_profiles_dir {
Path ::new ( profiles_dir ). join ( format! ( " {} .set" , ea ))
} else {
ea_path . with_extension ( "set" )
};
fs ::write ( & set_path , set_content ) ? ;
Ok ( json! ({
"content" : [{ "type" : "text" , "text" : json ! ({
"success" : true ,
"ea" : ea ,
"inputs_found" : inputs . len (),
"inputs" : inputs ,
"set_file" : set_path . to_string_lossy (). to_string (),
"hint" : "Edit .set file to modify parameter values, then use with run_backtest set_file=..."
}). to_string () }],
"isError" : false
}))
}
/// Export backtest report to various formats
pub async fn handle_export_report ( _config : & Config , args : & Value ) -> Result < Value > {
let report_dir = args . get ( "report_dir" )
. and_then ( | v | v . as_str ())
. ok_or_else ( || anyhow ::anyhow! ( "report_dir is required" )) ? ;
let format = args . get ( "format" ). and_then ( | v | v . as_str ()). unwrap_or ( "csv" );
let output_path = args . get ( "output_path" ). and_then ( | v | v . as_str ());
let path = Path ::new ( report_dir );
let metrics_path = path . join ( "metrics.json" );
2026-04-19 08:19:45 +07:00
let _deals_path = path . join ( "deals.csv" );
2026-04-19 08:11:17 +07:00
// Read metrics
let metrics : Value = if metrics_path . exists () {
fs ::read_to_string ( & metrics_path )
. ok ()
. and_then ( | c | serde_json ::from_str ( & c ). ok ())
. unwrap_or ( json! ({}))
} else {
json! ({})
};
2026-04-19 08:19:45 +07:00
// Determine output file; restrict user-supplied path to the report directory.
2026-04-19 08:11:17 +07:00
let output = match output_path {
2026-04-19 08:19:45 +07:00
Some ( p ) => safe_output_path ( p , path ) ? ,
None => path . join ( format! ( "report. {} " , format )),
2026-04-19 08:11:17 +07:00
};
let content = match format {
"csv" => {
// Simple CSV export of metrics
let mut csv = "Metric,Value \n " . to_string ();
if let Some ( obj ) = metrics . as_object () {
for ( key , value ) in obj {
csv . push_str ( & format! ( " {} , {} \n " , key , value ));
}
}
csv
}
"md" => {
// Markdown format
let mut md = format! ( "# Backtest Report: {} \n\n " , metrics . get ( "expert" ). and_then ( | v | v . as_str ()). unwrap_or ( "Unknown" ));
md . push_str ( "## Summary \n\n " );
if let Some ( obj ) = metrics . as_object () {
for ( key , value ) in obj {
md . push_str ( & format! ( "- ** {} **: {} \n " , key , value ));
}
}
md
}
_ => metrics . to_string () // JSON fallback
};
fs ::write ( & output , content ) ? ;
Ok ( json! ({
"content" : [{ "type" : "text" , "text" : json ! ({
"success" : true ,
"format" : format ,
"output_file" : output . to_string_lossy (). to_string (),
"source" : report_dir ,
"hint" : "Exported report ready for analysis or sharing."
}). to_string () }],
"isError" : false
}))
}