Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2fef0db3c4 | |||
| 95ceef3e3f | |||
| 662e9e324c | |||
| 6d1f940887 | |||
| 41c8b36826 | |||
| 42318b040a | |||
| fdb45b8eed | |||
| b63bda64ab | |||
| cbed3d15af | |||
| 8cce92c409 | |||
| 2b136b845b | |||
| d41c51bae4 | |||
| eae612df75 | |||
| 94eb8927fd | |||
| 2104fabe50 | |||
| 51f19640c3 | |||
| 1ec0adb6e6 | |||
| 397f893bfc |
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["server.json"],
|
||||
"url": "https://raw.githubusercontent.com/modelcontextprotocol/specification/main/schema/2024-11-05/schema.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+1
-1
@@ -481,7 +481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mt5-quant"
|
||||
version = "1.27.0"
|
||||
version = "1.29.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mt5-quant"
|
||||
version = "1.27.0"
|
||||
version = "1.29.0"
|
||||
edition = "2021"
|
||||
description = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP"
|
||||
authors = ["masdevid <masdevid@example.com>"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MT5-Quant
|
||||
|
||||
**MCP server for MT5 strategy development on macOS/Linux.** 43 tools to compile, backtest, analyze, and optimize MQL5 Expert Advisors — no Windows required.
|
||||
**MCP server for MT5 strategy development on macOS/Linux.** 57 tools to compile, backtest, analyze, optimize, and manage MQL5 Expert Advisors — no Windows required.
|
||||
|
||||
```
|
||||
You: "Backtest MyEA Jan-Mar, what caused the February drawdown?"
|
||||
@@ -23,15 +23,61 @@ Claude: [compile → clean → backtest → analyze 1,847 deals]
|
||||
|
||||
## Quick Install
|
||||
|
||||
### 1. Download & Setup
|
||||
|
||||
```bash
|
||||
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
|
||||
tar -xzf mt5.tar.gz
|
||||
bash scripts/setup.sh
|
||||
```
|
||||
|
||||
### 2. Register MCP Server
|
||||
|
||||
#### Claude Code
|
||||
|
||||
```bash
|
||||
# Navigate to your project directory first
|
||||
cd /path/to/your/mt5-quant
|
||||
|
||||
# Register MCP server (requires absolute path)
|
||||
claude mcp add MT5-Quant -- $(pwd)/mt5-quant
|
||||
|
||||
# Verify installation
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
#### Windsurf
|
||||
|
||||
Add to `~/.windsurf/config.yaml`:
|
||||
|
||||
```yaml
|
||||
mcpServers:
|
||||
mt5-quant:
|
||||
command: /absolute/path/to/mt5-quant
|
||||
env:
|
||||
MT5_MCP_HOME: /absolute/path/to/mt5-quant
|
||||
```
|
||||
|
||||
Or use the config command:
|
||||
|
||||
```bash
|
||||
# Get absolute path
|
||||
which mt5-quant
|
||||
|
||||
# Add to Windsurf config
|
||||
cat >> ~/.windsurf/config.yaml << EOF
|
||||
mcpServers:
|
||||
mt5-quant:
|
||||
command: $(which mt5-quant)
|
||||
env:
|
||||
MT5_MCP_HOME: $(dirname $(which mt5-quant))
|
||||
EOF
|
||||
```
|
||||
|
||||
**[Full Setup →](docs/QUICKSTART.md)**
|
||||
|
||||
> **Note:** MCP servers require **absolute paths**. Use `$(pwd)` or full path like `/Users/name/mt5-quant/mt5-quant`, not relative paths like `./mt5-quant`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```
|
||||
@@ -47,12 +93,12 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
|
||||
| [QUICKSTART.md](docs/QUICKSTART.md) | Complete setup for macOS/Linux |
|
||||
| [CONFIG.md](docs/CONFIG.md) | Configuration reference |
|
||||
| [WINDSURF.md](docs/WINDSURF.md) | Windsurf IDE integration |
|
||||
| [TOOLS.md](docs/MCP_TOOLS.md) | All 43 tools (31 documented) |
|
||||
| [TOOLS.md](docs/MCP_TOOLS.md) | All 57 tools documented |
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design and internals |
|
||||
| [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Common issues |
|
||||
| [REMOTE_AGENTS.md](docs/REMOTE_AGENTS.md) | Linux optimization agents |
|
||||
|
||||
## MCP Tools (43)
|
||||
## MCP Tools (57)
|
||||
|
||||
### Core workflow
|
||||
|
||||
@@ -120,6 +166,30 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
|
||||
| `cache_status` | MT5 tester cache size breakdown by symbol — check before cleaning |
|
||||
| `clean_cache` | Delete tester cache files; supports per-symbol and `dry_run` |
|
||||
|
||||
### Pre-flight & Validation
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_active_account` | Get current MT5 account session (login, server, available symbols) |
|
||||
| `check_symbol_data_status` | Validate symbol has sufficient history data for date range |
|
||||
| `check_mt5_status` | Check if MT5 terminal is installed and ready |
|
||||
| `validate_ea_syntax` | Pre-compile syntax check without running full compilation |
|
||||
|
||||
### Project Management
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `init_project` | Scaffold new MQL5 project with templates (scalper/swing/grid/basic) |
|
||||
| `create_set_template` | Generate .set parameter file from EA input variables |
|
||||
| `export_report` | Export backtest report to CSV, JSON, or Markdown |
|
||||
|
||||
### History & Comparison
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_backtest_history` | List all backtests for EA/symbol with summary metrics |
|
||||
| `compare_backtests` | Compare 2+ backtest results side-by-side with analysis |
|
||||
|
||||
### .set file — read / write
|
||||
|
||||
| Tool | Description |
|
||||
@@ -138,6 +208,16 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
|
||||
| `diff_set_files` | Side-by-side diff of two `.set` files — only changed params returned |
|
||||
| `set_from_optimization` | Generate a clean backtest `.set` from `get_optimization_results` params; optionally narrow sweep |
|
||||
|
||||
### Search & Discovery
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `search_experts` | Search EAs by name pattern across all directories |
|
||||
| `search_indicators` | Search indicators by name pattern |
|
||||
| `search_scripts` | Search scripts by name pattern |
|
||||
| `copy_indicator_to_project` | Copy indicator to project directory |
|
||||
| `copy_script_to_project` | Copy script to project directory |
|
||||
|
||||
Full schema: [docs/MCP_TOOLS.md](docs/MCP_TOOLS.md)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
+347
-3
@@ -2,11 +2,13 @@
|
||||
|
||||
Full input/output schemas for MT5-Quant tools.
|
||||
|
||||
> **Documentation Status:** This file documents 31 of 43 total tools. Missing:
|
||||
> **Documentation Status:** This file documents 49 of 57 total tools. Missing:
|
||||
> - `list_experts`, `list_indicators`, `list_scripts`
|
||||
> - `healthcheck`
|
||||
> - `search_reports`, `get_latest_report`
|
||||
> - `healthcheck`, `list_symbols`
|
||||
> - `search_reports`, `get_latest_report`, `list_reports`, `prune_reports`, `tail_log`
|
||||
> - Granular analytics: `analyze_monthly_pnl`, `analyze_drawdown_events`, `analyze_top_losses`, `analyze_loss_sequences`, `analyze_position_pairs`, `analyze_direction_bias`, `analyze_streaks`, `analyze_concurrent_peak`
|
||||
> - Archive/history tools: `archive_report`, `archive_all_reports`, `get_history`, `annotate_history`, `promote_to_baseline`
|
||||
> - Experts search: `search_experts`, `search_indicators`, `search_scripts`, `copy_indicator_to_project`, `copy_script_to_project`
|
||||
|
||||
---
|
||||
|
||||
@@ -1237,6 +1239,323 @@ List all `.set` files in the MT5 tester profiles directory with param counts, sw
|
||||
|
||||
---
|
||||
|
||||
## `get_active_account`
|
||||
|
||||
Get current MT5 account session information: login, server, and available symbols. This is essential for pre-flight checks to ensure symbol availability before backtesting.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{} // No parameters
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
ready_for_backtest: boolean; // true if account exists and symbols available
|
||||
account: {
|
||||
login: string;
|
||||
server: string;
|
||||
} | null;
|
||||
server: string; // Active server name
|
||||
available_servers: string[]; // All servers with history data
|
||||
symbols: string[]; // Symbols available for active server
|
||||
symbol_count: number;
|
||||
hint: string; // "Ready for backtesting" or instructions
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `check_symbol_data_status`
|
||||
|
||||
Validate if a symbol has sufficient historical tick data for a specified date range before running backtest. Prevents failed backtests due to missing history data.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
symbol: string; // e.g., "XAUUSDc"
|
||||
from_date: string; // "YYYY.MM.DD"
|
||||
to_date: string; // "YYYY.MM.DD"
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
symbol: string;
|
||||
server: string;
|
||||
has_sufficient_data: boolean;
|
||||
requested_range: { from: string; to: string };
|
||||
data_range: string; // "YYYY.MM.DD - YYYY.MM.DD" or "unknown"
|
||||
years_available: number; // Count of years with data
|
||||
hcc_files_count: number; // Number of history cache files
|
||||
warnings: string[] | null; // Data range issues
|
||||
suggestion: string; // Action recommendation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `check_mt5_status`
|
||||
|
||||
Check if MT5 terminal is properly installed and configured. Returns comprehensive status of all required components.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{} // No parameters
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
terminal_ready: boolean; // true if all components present
|
||||
checks: {
|
||||
mt5_dir_exists: boolean;
|
||||
terminal64_exe: boolean;
|
||||
metaeditor64_exe: boolean;
|
||||
metatester64_exe: boolean;
|
||||
wine_executable: boolean;
|
||||
wine_path: string | null;
|
||||
};
|
||||
mt5_version: string | null;
|
||||
current_account: {
|
||||
login: string;
|
||||
server: string;
|
||||
} | null;
|
||||
hint: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `get_backtest_history`
|
||||
|
||||
List all backtests previously run for a specific EA and/or symbol with summary metrics. Use for tracking performance over time.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
expert?: string; // Filter by EA name
|
||||
symbol?: string; // Filter by symbol
|
||||
limit?: number; // Max results (default: 10)
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
count: number;
|
||||
total: number;
|
||||
filters: {
|
||||
expert: string | null;
|
||||
symbol: string | null;
|
||||
};
|
||||
history: Array<{
|
||||
report_dir: string;
|
||||
date: string | null;
|
||||
expert: string | null;
|
||||
symbol: string | null;
|
||||
period: string | null;
|
||||
profit: number | null;
|
||||
profit_factor: number | null;
|
||||
expected_payoff: number | null;
|
||||
drawdown_pct: number | null;
|
||||
total_trades: number | null;
|
||||
win_rate: number | null;
|
||||
}>;
|
||||
hint: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `compare_backtests`
|
||||
|
||||
Compare two or more backtest results side-by-side with key metrics analysis. Includes profit/drawdown differences and verdict on which performed better.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
report_dirs: string[]; // List of report directory paths to compare
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
count: number;
|
||||
comparisons: Array<{
|
||||
report_dir: string;
|
||||
expert: string | null;
|
||||
symbol: string | null;
|
||||
net_profit: number | null;
|
||||
profit_factor: number | null;
|
||||
drawdown_pct: number | null;
|
||||
total_trades: number | null;
|
||||
win_rate: number | null;
|
||||
expected_payoff: number | null;
|
||||
recovery_factor: number | null;
|
||||
sharpe_ratio: number | null;
|
||||
}>;
|
||||
analysis: Array<{
|
||||
compare_to: string | null;
|
||||
report: string | null;
|
||||
profit_diff: number;
|
||||
profit_pct_change: number;
|
||||
drawdown_diff: number;
|
||||
profit_factor_diff: number;
|
||||
verdict: "better" | "worse" | "mixed";
|
||||
}> | null;
|
||||
verdict: string | null; // "Best: <report_dir>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `init_project`
|
||||
|
||||
Create a new MQL5 project with standard directory structure and template files. Supports scalper, swing, grid, and basic templates.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: string; // Project name (used for EA filename)
|
||||
template?: "scalper" | "swing" | "grid" | "basic"; // Default: "basic"
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
project_name: string;
|
||||
template: string;
|
||||
created_files: string[]; // Paths to created files
|
||||
hint: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `validate_ea_syntax`
|
||||
|
||||
Perform pre-compile syntax check on MQL5 source file without running full compilation. Detects common issues before expensive MetaEditor compilation.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: string; // Path to .mq5 source file
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
valid: boolean;
|
||||
path: string;
|
||||
checks: {
|
||||
has_on_init: boolean;
|
||||
has_on_tick: boolean;
|
||||
has_on_deinit: boolean;
|
||||
lines: number;
|
||||
};
|
||||
errors: Array<{
|
||||
line: number;
|
||||
message: string;
|
||||
severity: "error";
|
||||
}> | null;
|
||||
warnings: Array<{
|
||||
line: number;
|
||||
message: string;
|
||||
severity: "warning";
|
||||
}> | null;
|
||||
hint: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `create_set_template`
|
||||
|
||||
Generate a .set parameter file template based on an EA's input variables. Automatically parses input declarations from source code.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
ea: string; // EA name or path to .mq5/.ex5 file
|
||||
output_path?: string; // Optional custom output path
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
ea: string;
|
||||
inputs_found: number;
|
||||
inputs: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
default: string;
|
||||
description: string | null;
|
||||
}>;
|
||||
set_file: string; // Path to generated file
|
||||
hint: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `export_report`
|
||||
|
||||
Export backtest report to various formats (CSV, JSON, Markdown) for external analysis or sharing.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
report_dir: string; // Path to backtest report directory
|
||||
format?: "csv" | "json" | "md"; // Default: "csv"
|
||||
output_path?: string; // Optional custom output file path
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
format: string;
|
||||
output_file: string;
|
||||
source: string;
|
||||
hint: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `archive_report`
|
||||
|
||||
Convert a backtest report directory into a compact JSON entry appended to `config/backtest_history.json`. Idempotent — re-archiving the same report is a no-op. Optionally deletes the source directory to reclaim disk space.
|
||||
@@ -1527,6 +1846,31 @@ clean_cache(symbol=XAUUSD, dry_run=true) → preview
|
||||
clean_cache(symbol=XAUUSD) → execute
|
||||
```
|
||||
|
||||
### Pre-flight validation
|
||||
|
||||
```
|
||||
get_active_account() → current login, server, available symbols
|
||||
check_symbol_data_status(symbol=XAUUSD, from=2025.01.01, to=2025.03.31)
|
||||
→ verify data availability before backtest
|
||||
check_mt5_status() → verify MT5 installation and readiness
|
||||
validate_ea_syntax(path=MyEA.mq5) → pre-compile syntax check
|
||||
```
|
||||
|
||||
### Project management
|
||||
|
||||
```
|
||||
init_project(name=MyStrategy, template=scalper) → scaffold new EA with template
|
||||
create_set_template(ea=MyEA) → generate .set from EA inputs
|
||||
export_report(report_dir=..., format=csv) → export to CSV/JSON/Markdown
|
||||
```
|
||||
|
||||
### History and comparison
|
||||
|
||||
```
|
||||
get_backtest_history(expert=MyEA, limit=10) → list past backtests with metrics
|
||||
compare_backtests(report_dirs=["dir1", "dir2"]) → side-by-side comparison
|
||||
```
|
||||
|
||||
### Working with set files
|
||||
|
||||
```
|
||||
|
||||
@@ -10,7 +10,7 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
VERSION=$(grep -E '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
|
||||
echo "=== Building MT5-Quant v${VERSION} ==="
|
||||
echo "=== Building MCP-MT5-Quant v${VERSION} ==="
|
||||
echo ""
|
||||
|
||||
# Clean previous builds
|
||||
@@ -19,7 +19,7 @@ mkdir -p "$PROJECT_ROOT/dist"
|
||||
|
||||
# Build current platform
|
||||
echo "Building for current platform..."
|
||||
cargo build --release
|
||||
RUSTFLAGS="-D warnings" cargo build --release
|
||||
|
||||
# Detect platform
|
||||
UNAME=$(uname -s)
|
||||
@@ -33,7 +33,7 @@ else
|
||||
PLATFORM="unknown"
|
||||
fi
|
||||
|
||||
PACKAGE_NAME="mt5-quant-${PLATFORM}"
|
||||
PACKAGE_NAME="mcp-mt5-quant-${PLATFORM}"
|
||||
PACKAGE_DIR="$PROJECT_ROOT/dist/${PACKAGE_NAME}"
|
||||
|
||||
echo "Packaging for ${PLATFORM}..."
|
||||
@@ -48,7 +48,7 @@ cp "$PROJECT_ROOT/config/mt5-quant.example.yaml" "$PACKAGE_DIR/config/"
|
||||
|
||||
# Copy docs
|
||||
cp "$PROJECT_ROOT/README.md" "$PACKAGE_DIR/"
|
||||
cp "$PROJECT_ROOT/WINDSURF_SETUP.md" "$PACKAGE_DIR/"
|
||||
cp "$PROJECT_ROOT/docs/WINDSURF.md" "$PACKAGE_DIR/WINDSURF_SETUP.md"
|
||||
cp "$PROJECT_ROOT/CLAUDE.md" "$PACKAGE_DIR/"
|
||||
|
||||
# Create tarball
|
||||
@@ -59,6 +59,7 @@ echo ""
|
||||
echo "=== Build Complete ==="
|
||||
echo ""
|
||||
echo "Package: dist/${PACKAGE_NAME}.tar.gz"
|
||||
echo "Binary: mt5-quant"
|
||||
echo "Size: $(du -h "${PACKAGE_NAME}.tar.gz" | cut -f1)"
|
||||
echo ""
|
||||
echo "Contents:"
|
||||
@@ -67,4 +68,5 @@ echo ""
|
||||
echo "To install:"
|
||||
echo " tar -xzf ${PACKAGE_NAME}.tar.gz"
|
||||
echo " cd ${PACKAGE_NAME}"
|
||||
echo " ./mt5-quant --help"
|
||||
echo " sudo cp mt5-quant /usr/local/bin/"
|
||||
echo " mt5-quant --help"
|
||||
|
||||
+5
-4
@@ -2,17 +2,18 @@
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.masdevid/mt5-quant",
|
||||
"title": "MT5-Quant",
|
||||
"description": "MCP server for MetaTrader 5 strategy development on macOS/Linux. 43 tools to compile MQL5 Expert Advisors, run backtests, analyze deals, and optimize parameters — no Windows required.",
|
||||
"description": "MCP server for MT5 strategy development. Compile, backtest, analyze MQL5 EAs on macOS/Linux.",
|
||||
"repository": {
|
||||
"url": "https://github.com/masdevid/mt5-quant",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "1.0.0",
|
||||
"version": "1.29.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "mcpb",
|
||||
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.0.0/mt5-quant-macos-arm64.tar.gz",
|
||||
"fileSha256": "5d647e44efa32ab9a1b8e16139a3a0e4a58408ce5993cc0bf14d551184124fbb",
|
||||
"version": "1.29.0",
|
||||
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.29.0/mcp-mt5-quant-macos-arm64.tar.gz",
|
||||
"fileSha256": "5d56099e01c6df0c16bc8aa28fffab2e3ba7502f283c3c8273b70e4192230f01",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub wine_executable: Option<String>,
|
||||
pub terminal_dir: Option<String>,
|
||||
pub experts_dir: Option<String>,
|
||||
pub tester_profiles_dir: Option<String>,
|
||||
pub tester_cache_dir: Option<String>,
|
||||
pub display_mode: Option<String>,
|
||||
pub backtest_symbol: Option<String>,
|
||||
pub backtest_deposit: Option<u32>,
|
||||
pub backtest_currency: Option<String>,
|
||||
pub backtest_leverage: Option<u32>,
|
||||
pub backtest_model: Option<u32>,
|
||||
pub backtest_timeframe: Option<String>,
|
||||
pub backtest_timeout: Option<u32>,
|
||||
pub opt_log_dir: Option<String>,
|
||||
pub opt_min_agents: Option<u32>,
|
||||
pub reports_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wine_executable: None,
|
||||
terminal_dir: None,
|
||||
experts_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,
|
||||
reports_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let config_path = Self::get_config_path();
|
||||
if !config_path.exists() {
|
||||
return Ok(Config::default());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)?;
|
||||
let mut config: HashMap<String, String> = HashMap::new();
|
||||
|
||||
// Parse simple YAML format (key: value)
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') || !line.contains(':') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
let key = key.trim().to_string();
|
||||
let value = value.trim()
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.to_string();
|
||||
|
||||
if !value.is_empty() && value != "null" && value != "~" {
|
||||
config.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Config {
|
||||
wine_executable: config.get("wine_executable").cloned(),
|
||||
terminal_dir: config.get("terminal_dir").cloned(),
|
||||
experts_dir: config.get("experts_dir").cloned(),
|
||||
tester_profiles_dir: config.get("tester_profiles_dir").cloned(),
|
||||
tester_cache_dir: config.get("tester_cache_dir").cloned(),
|
||||
display_mode: config.get("display_mode").cloned(),
|
||||
backtest_symbol: config.get("backtest_symbol").cloned(),
|
||||
backtest_deposit: config.get("backtest_deposit").and_then(|s| s.parse().ok()),
|
||||
backtest_currency: config.get("backtest_currency").cloned(),
|
||||
backtest_leverage: config.get("backtest_leverage").and_then(|s| s.parse().ok()),
|
||||
backtest_model: config.get("backtest_model").and_then(|s| s.parse().ok()),
|
||||
backtest_timeframe: config.get("backtest_timeframe").cloned(),
|
||||
backtest_timeout: config.get("backtest_timeout").and_then(|s| s.parse().ok()),
|
||||
opt_log_dir: config.get("opt_log_dir").cloned(),
|
||||
opt_min_agents: config.get("opt_min_agents").and_then(|s| s.parse().ok()),
|
||||
reports_dir: config.get("reports_dir").cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_config_path() -> std::path::PathBuf {
|
||||
if let Ok(home) = std::env::var("MT5_MCP_HOME") {
|
||||
Path::new(&home).join("config").join("mt5-quant.yaml")
|
||||
} else {
|
||||
let base_path = dirs::home_dir()
|
||||
.unwrap_or_else(|| Path::new(".").to_path_buf())
|
||||
.join(".config")
|
||||
.join("mt5-quant");
|
||||
|
||||
if base_path.join("config").join("mt5-quant.yaml").exists() {
|
||||
base_path.join("config").join("mt5-quant.yaml")
|
||||
} else {
|
||||
// Development mode - use parent directory
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap_or(Path::new(".")).join("config").join("mt5-quant.yaml")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
"reports_dir" => self.reports_dir.clone().unwrap_or_else(|| "reports".to_string()),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-2
@@ -6,7 +6,6 @@ mod pipeline;
|
||||
mod storage;
|
||||
mod tools;
|
||||
|
||||
mod config;
|
||||
mod mcp_server;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -41,8 +40,11 @@ pub struct McpRequest {
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct McpResponse {
|
||||
jsonrpc: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<McpError>,
|
||||
}
|
||||
|
||||
@@ -50,6 +52,7 @@ pub struct McpResponse {
|
||||
pub struct McpError {
|
||||
code: i32,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
data: Option<Value>,
|
||||
}
|
||||
|
||||
@@ -79,7 +82,9 @@ pub struct ToolCapabilities {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
@@ -112,6 +117,11 @@ async fn run_stdio_server() -> Result<()> {
|
||||
let server_clone = server.clone();
|
||||
match serde_json::from_str::<McpRequest>(line) {
|
||||
Ok(request) => {
|
||||
// Notifications have no id — don't send a response
|
||||
if request.id.is_none() {
|
||||
server_clone.handle_notification(request).await;
|
||||
continue;
|
||||
}
|
||||
let response = server_clone.handle_request(request).await;
|
||||
let response_json = serde_json::to_string(&response)?;
|
||||
println!("{}", response_json);
|
||||
@@ -181,6 +191,10 @@ async fn handle_connection(socket: tokio::net::TcpStream) -> Result<()> {
|
||||
|
||||
match serde_json::from_str::<McpRequest>(line) {
|
||||
Ok(request) => {
|
||||
if request.id.is_none() {
|
||||
server.handle_notification(request).await;
|
||||
continue;
|
||||
}
|
||||
let response = server.handle_request(request).await;
|
||||
let response_json = serde_json::to_string(&response)? + "\n";
|
||||
writer.write_all(response_json.as_bytes()).await?;
|
||||
|
||||
+113
-11
@@ -4,10 +4,20 @@ use tokio::sync::Mutex;
|
||||
|
||||
use crate::{models::Config as ModelsConfig, tools::ToolHandler, McpError, McpRequest, McpResponse};
|
||||
|
||||
/// Auto-verify result stored after first initialization
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
struct AutoVerifyResult {
|
||||
all_ok: bool,
|
||||
hint: String,
|
||||
config_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct McpServer {
|
||||
initialized: Arc<Mutex<bool>>,
|
||||
tool_handler: Arc<ToolHandler>,
|
||||
auto_verify_result: Arc<Mutex<Option<AutoVerifyResult>>>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
@@ -16,6 +26,88 @@ impl McpServer {
|
||||
Self {
|
||||
initialized: Arc::new(Mutex::new(false)),
|
||||
tool_handler: Arc::new(ToolHandler::new(config)),
|
||||
auto_verify_result: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
|
||||
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)
|
||||
} 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)
|
||||
} 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())
|
||||
} else if !wine_ok {
|
||||
"Wine/CrossOver not found - required for MT5 execution".to_string()
|
||||
} else if !term_ok {
|
||||
"MT5 directory not found - check installation".to_string()
|
||||
} 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) {
|
||||
let guard = self.auto_verify_result.lock().await;
|
||||
match guard.as_ref() {
|
||||
Some(result) => (Some(result.all_ok), result.hint.clone()),
|
||||
None => (None, "Checking environment...".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a notification (no id — no response sent)
|
||||
pub async fn handle_notification(&self, request: McpRequest) {
|
||||
match request.method.as_str() {
|
||||
"notifications/initialized" => {
|
||||
// Client confirms initialization is complete — no action needed
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("Unhandled notification: {}", request.method);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,22 +127,32 @@ impl McpServer {
|
||||
"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",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"setup": {
|
||||
"hint": "Auto-verification running... Use verify_setup tool for detailed status",
|
||||
}
|
||||
});
|
||||
|
||||
McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: request.id,
|
||||
result: Some(json!(crate::InitializeResult {
|
||||
protocol_version: negotiated_version.to_string(),
|
||||
capabilities: crate::ServerCapabilities {
|
||||
experimental: json!({}),
|
||||
tools: crate::ToolCapabilities {
|
||||
list_changed: false,
|
||||
result: Some(json!({
|
||||
"protocolVersion": negotiated_version,
|
||||
"capabilities": {
|
||||
"experimental": {},
|
||||
"tools": {
|
||||
"listChanged": false,
|
||||
},
|
||||
},
|
||||
server_info: crate::ServerInfo {
|
||||
name: "MT5-Quant".to_string(),
|
||||
version: "1.27.0".to_string(),
|
||||
},
|
||||
"serverInfo": server_info,
|
||||
})),
|
||||
error: None,
|
||||
}
|
||||
@@ -73,7 +175,7 @@ impl McpServer {
|
||||
McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: request.id,
|
||||
result: Some(crate::tools::get_tools_list()),
|
||||
result: Some(json!({"tools": crate::tools::get_tools_list()})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
+202
-5
@@ -4,6 +4,77 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Active MT5 account session info
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CurrentAccount {
|
||||
pub login: String,
|
||||
pub server: String,
|
||||
}
|
||||
|
||||
impl CurrentAccount {
|
||||
/// Parse common.ini to extract active account info
|
||||
/// Handles UTF-16LE encoding which MT5 uses on Windows/Wine
|
||||
pub fn from_common_ini(terminal_dir: &Path) -> Option<Self> {
|
||||
let common_ini = terminal_dir.join("config").join("common.ini");
|
||||
if !common_ini.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try reading as UTF-16LE first (MT5 default encoding)
|
||||
let bytes = fs::read(&common_ini).ok()?;
|
||||
let content = if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
|
||||
// UTF-16LE BOM detected
|
||||
let start = if bytes.len() >= 2 { 2 } else { 0 };
|
||||
let u16_slice: Vec<u16> = bytes[start..]
|
||||
.chunks(2)
|
||||
.map(|chunk| {
|
||||
if chunk.len() == 2 {
|
||||
u16::from_le_bytes([chunk[0], chunk[1]])
|
||||
} else {
|
||||
chunk[0] as u16
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
String::from_utf16(&u16_slice).ok()?
|
||||
} else {
|
||||
// Try UTF-8 fallback
|
||||
String::from_utf8(bytes).ok()?
|
||||
};
|
||||
|
||||
let mut login = None;
|
||||
let mut server = None;
|
||||
|
||||
for line in content.lines() {
|
||||
// Remove null bytes and control characters but keep printable ASCII and valid Unicode
|
||||
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());
|
||||
if let Some(v) = val {
|
||||
if !v.is_empty() {
|
||||
login = Some(v);
|
||||
}
|
||||
}
|
||||
} else if trimmed.starts_with("Server=") {
|
||||
let val = trimmed.strip_prefix("Server=").map(|s| s.trim().to_string());
|
||||
if let Some(v) = val {
|
||||
if !v.is_empty() {
|
||||
server = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (login, server) {
|
||||
(Some(l), Some(s)) => Some(Self { login: l, server: s }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub wine_executable: Option<String>,
|
||||
@@ -73,13 +144,52 @@ impl Config {
|
||||
Ok(discovered)
|
||||
}
|
||||
|
||||
/// The canonical writable config location: $MT5_MCP_HOME/config/mt5-quant.yaml
|
||||
/// or ~/.config/mt5-quant/config/mt5-quant.yaml.
|
||||
/// The canonical writable config location, checked in order:
|
||||
/// 1. $MT5_MCP_HOME/config/mt5-quant.yaml (user override)
|
||||
/// 2. Config next to binary (for portable/development installs)
|
||||
/// 3. ~/.config/mt5-quant/config/mt5-quant.yaml (standard location)
|
||||
/// 4. Development fallback (project directory)
|
||||
pub fn writable_config_path() -> PathBuf {
|
||||
// 1. Check env override first
|
||||
if let Ok(home) = std::env::var("MT5_MCP_HOME") {
|
||||
return Path::new(&home).join("config").join("mt5-quant.yaml");
|
||||
}
|
||||
Self::installation_dir().join("config").join("mt5-quant.yaml")
|
||||
|
||||
// 2. Check if config exists next to the binary
|
||||
if let Some(binary_dir) = Self::binary_dir() {
|
||||
let local_config = binary_dir.join("config").join("mt5-quant.yaml");
|
||||
if local_config.exists() {
|
||||
return local_config;
|
||||
}
|
||||
|
||||
// If binary is in a non-standard path (not system bin), use it
|
||||
let binary_str = binary_dir.to_string_lossy();
|
||||
if !binary_str.starts_with("/usr/local/bin")
|
||||
&& !binary_str.starts_with("/usr/bin")
|
||||
&& !binary_str.starts_with("/bin")
|
||||
{
|
||||
return binary_dir.join("config").join("mt5-quant.yaml");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check standard location
|
||||
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()
|
||||
.unwrap_or(manifest_dir)
|
||||
.join("config")
|
||||
.join("mt5-quant.yaml");
|
||||
if dev_config.exists() {
|
||||
return dev_config;
|
||||
}
|
||||
|
||||
// 5. Fall back to standard location (will be created if not exists)
|
||||
Self::standard_config_dir().join("config").join("mt5-quant.yaml")
|
||||
}
|
||||
|
||||
// ── Auto-discovery ────────────────────────────────────────────────────────
|
||||
@@ -210,6 +320,8 @@ impl Config {
|
||||
wine_executable: {wine}\n\
|
||||
terminal_dir: {term}\n\
|
||||
experts_dir: {exp}\n\
|
||||
indicators_dir: {ind}\n\
|
||||
scripts_dir: {scr}\n\
|
||||
tester_profiles_dir: {prof}\n\
|
||||
tester_cache_dir: {cache}\n\
|
||||
display_mode: {disp}\n\
|
||||
@@ -227,6 +339,8 @@ impl Config {
|
||||
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),
|
||||
@@ -321,11 +435,40 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Root of the MCP installation: $MT5_MCP_HOME or ~/.config/mt5-quant
|
||||
/// Root of the MCP installation.
|
||||
/// Priority: $MT5_MCP_HOME > binary parent dir > ~/.config/mt5-quant
|
||||
pub fn installation_dir() -> PathBuf {
|
||||
// 1. Check env override first
|
||||
if let Ok(home) = std::env::var("MT5_MCP_HOME") {
|
||||
return Path::new(&home).to_path_buf();
|
||||
}
|
||||
|
||||
// 2. Check if binary is in a non-standard location (development/portable)
|
||||
// with an existing config file
|
||||
if let Some(binary_dir) = Self::binary_dir() {
|
||||
let binary_str = binary_dir.to_string_lossy();
|
||||
let is_system_path = binary_str.starts_with("/usr/local/bin")
|
||||
|| binary_str.starts_with("/usr/bin")
|
||||
|| binary_str.starts_with("/bin");
|
||||
|
||||
if !is_system_path && binary_dir.join("config").join("mt5-quant.yaml").exists() {
|
||||
return binary_dir;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fall back to standard location
|
||||
Self::standard_config_dir()
|
||||
}
|
||||
|
||||
/// Get the directory where the current binary is located
|
||||
fn binary_dir() -> Option<PathBuf> {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|p| p.to_path_buf()))
|
||||
}
|
||||
|
||||
/// Standard config directory in user's home
|
||||
fn standard_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| Path::new(".").to_path_buf())
|
||||
.join(".config")
|
||||
@@ -363,7 +506,8 @@ impl Config {
|
||||
|
||||
/// Scan Bases/*/history/ for symbol directories that contain at least one .hcc file.
|
||||
/// Returns deduplicated, sorted list of symbol names available for backtesting.
|
||||
pub fn discover_symbols(&self) -> Vec<String> {
|
||||
/// If server_filter is provided, only scans that specific server's directory.
|
||||
pub fn discover_symbols(&self, server_filter: Option<&str>) -> Vec<String> {
|
||||
let mt5_dir = match self.mt5_dir() {
|
||||
Some(d) => d,
|
||||
None => return Vec::new(),
|
||||
@@ -379,6 +523,19 @@ impl Config {
|
||||
// Bases/{server}/history/{symbol}/{year}.hcc
|
||||
if let Ok(servers) = fs::read_dir(&bases_dir) {
|
||||
for server in servers.filter_map(|e| e.ok()) {
|
||||
let server_name_os = server.file_name();
|
||||
let server_name = server_name_os.to_str().unwrap_or("");
|
||||
|
||||
// Skip non-directory entries and filter by server if specified
|
||||
if server_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(filter) = server_filter {
|
||||
if server_name != filter {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let history_dir = server.path().join("history");
|
||||
if !history_dir.is_dir() {
|
||||
continue;
|
||||
@@ -416,4 +573,44 @@ impl Config {
|
||||
sorted.sort();
|
||||
sorted
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
/// Discover symbols for the currently active account/server only
|
||||
pub fn discover_symbols_for_active_account(&self) -> Vec<String> {
|
||||
match self.current_account() {
|
||||
Some(account) => self.discover_symbols(Some(&account.server)),
|
||||
None => self.discover_symbols(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all available servers that have symbol data
|
||||
pub fn available_servers(&self) -> Vec<String> {
|
||||
let mt5_dir = match self.mt5_dir() {
|
||||
Some(d) => d,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let bases_dir = mt5_dir.join("Bases");
|
||||
if !bases_dir.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut servers = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&bases_dir) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
servers.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
servers.sort();
|
||||
servers
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,6 +3,6 @@ pub mod deals;
|
||||
pub mod metrics;
|
||||
pub mod report;
|
||||
|
||||
pub use config::Config;
|
||||
pub use config::{Config, CurrentAccount};
|
||||
pub use deals::Deal;
|
||||
pub use metrics::Metrics;
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tokio::process::Command as AsyncCommand;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::models::Config;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Mt5Manager {
|
||||
@@ -35,7 +35,7 @@ impl Mt5Manager {
|
||||
let mut all_ok = true;
|
||||
|
||||
// Check config file
|
||||
let config_path = Config::get_config_path();
|
||||
let config_path = Config::writable_config_path();
|
||||
if config_path.exists() {
|
||||
checks.insert("config_file".to_string(), json!({
|
||||
"ok": true,
|
||||
|
||||
@@ -53,3 +53,101 @@ pub fn tool_list_scripts() -> Value {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_experts() -> Value {
|
||||
json!({
|
||||
"name": "search_experts",
|
||||
"description": "Search for Expert Advisors by name pattern across MT5 Experts directory and subdirectories",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Search pattern (case-insensitive substring match)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_indicators() -> Value {
|
||||
json!({
|
||||
"name": "search_indicators",
|
||||
"description": "Search for indicators by name pattern across MT5 directories and subdirectories",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Search pattern (case-insensitive substring match)"
|
||||
},
|
||||
"include_builtin": {
|
||||
"type": "boolean",
|
||||
"description": "Include built-in MT5 indicators in search",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_scripts() -> Value {
|
||||
json!({
|
||||
"name": "search_scripts",
|
||||
"description": "Search for scripts by name pattern across MT5 Scripts directory and subdirectories",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Search pattern (case-insensitive substring match)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_copy_indicator_to_project() -> Value {
|
||||
json!({
|
||||
"name": "copy_indicator_to_project",
|
||||
"description": "Copy an indicator file from MT5 Indicators directory to the project directory",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"required": ["source_path"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_copy_script_to_project() -> Value {
|
||||
json!({
|
||||
"name": "copy_script_to_project",
|
||||
"description": "Copy a script file from MT5 Scripts directory to the project directory",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"required": ["source_path"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod optimization;
|
||||
pub mod reports;
|
||||
pub mod setfiles;
|
||||
pub mod system;
|
||||
pub mod utility;
|
||||
|
||||
pub fn get_tools_list() -> Value {
|
||||
let tools = vec![
|
||||
@@ -38,10 +39,25 @@ pub fn get_tools_list() -> Value {
|
||||
experts::tool_list_experts(),
|
||||
experts::tool_list_indicators(),
|
||||
experts::tool_list_scripts(),
|
||||
experts::tool_search_experts(),
|
||||
experts::tool_search_indicators(),
|
||||
experts::tool_search_scripts(),
|
||||
experts::tool_copy_indicator_to_project(),
|
||||
experts::tool_copy_script_to_project(),
|
||||
// System
|
||||
system::tool_verify_setup(),
|
||||
system::tool_list_symbols(),
|
||||
system::tool_healthcheck(),
|
||||
system::tool_get_active_account(),
|
||||
// Utility (8 tools)
|
||||
utility::tool_check_symbol_data_status(),
|
||||
utility::tool_get_backtest_history(),
|
||||
utility::tool_compare_backtests(),
|
||||
utility::tool_init_project(),
|
||||
utility::tool_validate_ea_syntax(),
|
||||
utility::tool_check_mt5_status(),
|
||||
utility::tool_create_set_template(),
|
||||
utility::tool_export_report(),
|
||||
// Set Files
|
||||
setfiles::tool_read_set_file(),
|
||||
setfiles::tool_write_set_file(),
|
||||
|
||||
@@ -35,3 +35,13 @@ pub fn tool_list_symbols() -> Value {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_active_account() -> Value {
|
||||
json!({
|
||||
"name": "get_active_account",
|
||||
"description": "Get the currently active MT5 account session info (login, server, available symbols). Use this before backtesting to ensure symbol compatibility.",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub fn tool_check_symbol_data_status() -> Value {
|
||||
json!({
|
||||
"name": "check_symbol_data_status",
|
||||
"description": "Validate if a symbol has sufficient historical tick data for a specified date range before running backtest",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"symbol": {
|
||||
"type": "string",
|
||||
"description": "Symbol to check (e.g., 'XAUUSDc')"
|
||||
},
|
||||
"from_date": {
|
||||
"type": "string",
|
||||
"description": "Start date in YYYY.MM.DD format"
|
||||
},
|
||||
"to_date": {
|
||||
"type": "string",
|
||||
"description": "End date in YYYY.MM.DD format"
|
||||
}
|
||||
},
|
||||
"required": ["symbol", "from_date", "to_date"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_backtest_history() -> Value {
|
||||
json!({
|
||||
"name": "get_backtest_history",
|
||||
"description": "List all backtests previously run for a specific EA and/or symbol with summary metrics",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expert": {
|
||||
"type": "string",
|
||||
"description": "EA name to filter by"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string",
|
||||
"description": "Symbol to filter by"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results (default: 10)",
|
||||
"default": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_compare_backtests() -> Value {
|
||||
json!({
|
||||
"name": "compare_backtests",
|
||||
"description": "Compare two or more backtest results side-by-side with key metrics analysis",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dirs": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "List of report directory paths to compare"
|
||||
}
|
||||
},
|
||||
"required": ["report_dirs"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_init_project() -> Value {
|
||||
json!({
|
||||
"name": "init_project",
|
||||
"description": "Create a new MQL5 project with standard directory structure and template files",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Project name (will be used for EA filename)"
|
||||
},
|
||||
"template": {
|
||||
"type": "string",
|
||||
"enum": ["scalper", "swing", "grid", "basic"],
|
||||
"description": "Project template type",
|
||||
"default": "basic"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_validate_ea_syntax() -> Value {
|
||||
json!({
|
||||
"name": "validate_ea_syntax",
|
||||
"description": "Perform pre-compile syntax check on MQL5 source file without running full compilation",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to .mq5 source file"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_check_mt5_status() -> Value {
|
||||
json!({
|
||||
"name": "check_mt5_status",
|
||||
"description": "Check if MT5 terminal is installed, properly configured, and ready to run",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_create_set_template() -> Value {
|
||||
json!({
|
||||
"name": "create_set_template",
|
||||
"description": "Generate a .set parameter file template based on EA's input variables",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ea": {
|
||||
"type": "string",
|
||||
"description": "EA name or path to .mq5/.ex5 file"
|
||||
},
|
||||
"output_path": {
|
||||
"type": "string",
|
||||
"description": "Optional output path for .set file"
|
||||
}
|
||||
},
|
||||
"required": ["ea"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_export_report() -> Value {
|
||||
json!({
|
||||
"name": "export_report",
|
||||
"description": "Export backtest report to various formats (CSV, JSON, Markdown)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": {
|
||||
"type": "string",
|
||||
"description": "Path to backtest report directory"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["csv", "json", "md"],
|
||||
"description": "Export format",
|
||||
"default": "csv"
|
||||
},
|
||||
"output_path": {
|
||||
"type": "string",
|
||||
"description": "Optional custom output file path"
|
||||
}
|
||||
},
|
||||
"required": ["report_dir"]
|
||||
}
|
||||
})
|
||||
}
|
||||
+119
-16
@@ -5,42 +5,145 @@ use std::path::Path;
|
||||
use crate::models::Config;
|
||||
use crate::pipeline::backtest::{BacktestParams, BacktestPipeline};
|
||||
|
||||
/// Pre-flight check result for backtest readiness
|
||||
#[derive(Debug)]
|
||||
struct BacktestPreflight {
|
||||
account: Option<crate::models::CurrentAccount>,
|
||||
available_symbols: Vec<String>,
|
||||
ea_exists: bool,
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
impl BacktestPreflight {
|
||||
fn check(config: &Config, expert: &str) -> Self {
|
||||
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));
|
||||
mq5_path.exists() || ex5_path.exists() || subdir_mq5.exists()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Self {
|
||||
account,
|
||||
available_symbols,
|
||||
ea_exists,
|
||||
server,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn is_ready(&self) -> bool {
|
||||
self.account.is_some() && !self.available_symbols.is_empty() && self.ea_exists
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value> {
|
||||
let expert = args.get("expert")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("expert is required"))?;
|
||||
|
||||
// Symbol pre-flight
|
||||
// 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");
|
||||
|
||||
// Check account session first
|
||||
if preflight.account.is_none() {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": "No active MT5 account session detected.",
|
||||
"hint": "Open MT5 and login to your trading account before running backtests.",
|
||||
"pre_check": "account_missing"
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Symbol pre-flight with account context
|
||||
let requested_symbol = args.get("symbol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let available = config.discover_symbols();
|
||||
|
||||
let symbol = if requested_symbol.is_empty() {
|
||||
let default = config.backtest_symbol.clone()
|
||||
.unwrap_or_else(|| "XAUUSD".to_string());
|
||||
if available.contains(&default) {
|
||||
default
|
||||
} else if let Some(first) = available.first() {
|
||||
tracing::warn!("Default symbol {} not found; using {}", default, first);
|
||||
// Use config default or first available symbol
|
||||
if let Some(default) = config.backtest_symbol.clone() {
|
||||
if preflight.available_symbols.contains(&default) {
|
||||
default
|
||||
} else if let Some(first) = preflight.available_symbols.first() {
|
||||
tracing::warn!("Default symbol {} not found for server {}; using {}", default, active_server, first);
|
||||
first.clone()
|
||||
} else {
|
||||
return Ok(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": "Download historical data in MT5 Strategy Tester for this server.",
|
||||
"pre_check": "no_symbols",
|
||||
"suggestion": "Use get_active_account to see available symbols."
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
} else if let Some(first) = preflight.available_symbols.first() {
|
||||
first.clone()
|
||||
} else {
|
||||
default
|
||||
}
|
||||
} else {
|
||||
if !available.is_empty() && !available.contains(&requested_symbol.to_string()) {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": format!("Symbol '{}' has no local history data.", requested_symbol),
|
||||
"available_symbols": available,
|
||||
"hint": "Use list_symbols to see all available symbols."
|
||||
"error": format!("No symbols available for backtesting on server '{}'.", active_server),
|
||||
"account": {
|
||||
"login": active_login,
|
||||
"server": active_server
|
||||
},
|
||||
"hint": "Download historical data in MT5 Strategy Tester for this server.",
|
||||
"pre_check": "no_symbols",
|
||||
"suggestion": "Use get_active_account to see available symbols."
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
if !preflight.available_symbols.is_empty() && !preflight.available_symbols.contains(&requested_symbol.to_string()) {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": format!("Symbol '{}' is not available for server '{}'.", requested_symbol, active_server),
|
||||
"account": {
|
||||
"login": active_login,
|
||||
"server": active_server
|
||||
},
|
||||
"requested_symbol": requested_symbol,
|
||||
"available_symbols": preflight.available_symbols,
|
||||
"hint": "The symbol may not have history data for this account's server. Use list_symbols to see available symbols.",
|
||||
"pre_check": "symbol_not_available",
|
||||
"suggestion": "Either switch to a different MT5 account with this symbol's data, or download history for this symbol on the current server."
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
requested_symbol.to_string()
|
||||
};
|
||||
|
||||
// EA existence check with context
|
||||
if !preflight.ea_exists {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": format!("EA '{}' not found in Experts directory.", expert),
|
||||
"hint": "Use search_experts or list_experts to find available EAs.",
|
||||
"pre_check": "ea_not_found"
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Date defaulting: past complete calendar month
|
||||
let (from_date, to_date) = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
use crate::compile::MqlCompiler;
|
||||
use crate::models::Config;
|
||||
|
||||
@@ -11,9 +10,12 @@ pub async fn handle_list_experts(config: &Config, args: &Value) -> Result<Value>
|
||||
let mut experts = Vec::new();
|
||||
|
||||
if let Some(experts_dir) = &config.experts_dir {
|
||||
if let Ok(entries) = fs::read_dir(experts_dir) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
for entry in WalkDir::new(experts_dir).max_depth(3) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
let is_compiled = path.extension()
|
||||
@@ -48,6 +50,51 @@ 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")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
|
||||
|
||||
let pattern_lower = pattern.to_lowercase();
|
||||
let mut matches = Vec::new();
|
||||
|
||||
if let Some(experts_dir) = &config.experts_dir {
|
||||
for entry in WalkDir::new(experts_dir).max_depth(3) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
if name_str.to_lowercase().contains(&pattern_lower) {
|
||||
let is_compiled = path.extension()
|
||||
.map(|e| e == "ex5")
|
||||
.unwrap_or(false);
|
||||
matches.push(json!({
|
||||
"name": name_str,
|
||||
"path": path.to_string_lossy().to_string(),
|
||||
"compiled": is_compiled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"pattern": pattern,
|
||||
"count": matches.len(),
|
||||
"matches": matches,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -56,9 +103,12 @@ pub async fn handle_list_indicators(config: &Config, args: &Value) -> Result<Val
|
||||
|
||||
// List custom indicators
|
||||
if let Some(indicators_dir) = &config.indicators_dir {
|
||||
if let Ok(entries) = fs::read_dir(indicators_dir) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
for entry in WalkDir::new(indicators_dir).max_depth(3) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
let is_compiled = path.extension()
|
||||
@@ -121,9 +171,12 @@ pub async fn handle_list_scripts(config: &Config, args: &Value) -> Result<Value>
|
||||
let mut scripts = Vec::new();
|
||||
|
||||
if let Some(scripts_dir) = &config.scripts_dir {
|
||||
if let Ok(entries) = fs::read_dir(scripts_dir) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
for entry in WalkDir::new(scripts_dir).max_depth(3) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
let is_compiled = path.extension()
|
||||
@@ -216,3 +269,251 @@ pub async fn handle_compile_ea(config: &Config, args: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_search_indicators(config: &Config, args: &Value) -> Result<Value> {
|
||||
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 pattern_lower = pattern.to_lowercase();
|
||||
|
||||
let mut matches = Vec::new();
|
||||
|
||||
// Search custom indicators recursively
|
||||
if let Some(indicators_dir) = &config.indicators_dir {
|
||||
for entry in WalkDir::new(indicators_dir).max_depth(3) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
if name_str.to_lowercase().contains(&pattern_lower) {
|
||||
let is_compiled = path.extension()
|
||||
.map(|e| e == "ex5")
|
||||
.unwrap_or(false);
|
||||
matches.push(json!({
|
||||
"name": name_str,
|
||||
"path": path.to_string_lossy().to_string(),
|
||||
"type": "custom",
|
||||
"compiled": is_compiled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search built-in indicators if requested
|
||||
if include_builtin {
|
||||
let builtin = vec![
|
||||
"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",
|
||||
];
|
||||
for name in builtin {
|
||||
if name.to_lowercase().contains(&pattern_lower) {
|
||||
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()));
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"pattern": pattern,
|
||||
"count": matches.len(),
|
||||
"matches": matches,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_search_scripts(config: &Config, args: &Value) -> Result<Value> {
|
||||
let pattern = args.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
|
||||
|
||||
let pattern_lower = pattern.to_lowercase();
|
||||
let mut matches = Vec::new();
|
||||
|
||||
if let Some(scripts_dir) = &config.scripts_dir {
|
||||
for entry in WalkDir::new(scripts_dir).max_depth(3) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
if name_str.to_lowercase().contains(&pattern_lower) {
|
||||
let is_compiled = path.extension()
|
||||
.map(|e| e == "ex5")
|
||||
.unwrap_or(false);
|
||||
matches.push(json!({
|
||||
"name": name_str,
|
||||
"path": path.to_string_lossy().to_string(),
|
||||
"compiled": is_compiled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"pattern": pattern,
|
||||
"count": matches.len(),
|
||||
"matches": matches,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_copy_indicator_to_project(config: &Config, args: &Value) -> Result<Value> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
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());
|
||||
|
||||
// Determine project directory
|
||||
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"))?;
|
||||
|
||||
let source = PathBuf::from(source_path);
|
||||
if !source.exists() {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": false,
|
||||
"error": format!("Source file not found: {}", source_path),
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Get extension
|
||||
let ext = source.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("mq5");
|
||||
|
||||
// Determine target name
|
||||
let target_filename = match target_name {
|
||||
Some(name) => format!("{}.{}", name, ext),
|
||||
None => source.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| format!("indicator.{}", ext)),
|
||||
};
|
||||
|
||||
let destination = project_dir.join(&target_filename);
|
||||
|
||||
// Copy file
|
||||
match std::fs::copy(&source, &destination) {
|
||||
Ok(bytes) => {
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"source": source_path,
|
||||
"destination": destination.to_string_lossy().to_string(),
|
||||
"bytes_copied": bytes,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": false,
|
||||
"error": format!("Failed to copy file: {}", e),
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_copy_script_to_project(config: &Config, args: &Value) -> Result<Value> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
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());
|
||||
|
||||
// Determine project directory
|
||||
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"))?;
|
||||
|
||||
let source = PathBuf::from(source_path);
|
||||
if !source.exists() {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": false,
|
||||
"error": format!("Source file not found: {}", source_path),
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Get extension
|
||||
let ext = source.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("mq5");
|
||||
|
||||
// Determine target name
|
||||
let target_filename = match target_name {
|
||||
Some(name) => format!("{}.{}", name, ext),
|
||||
None => source.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| format!("script.{}", ext)),
|
||||
};
|
||||
|
||||
let destination = project_dir.join(&target_filename);
|
||||
|
||||
// Copy file
|
||||
match std::fs::copy(&source, &destination) {
|
||||
Ok(bytes) => {
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"source": source_path,
|
||||
"destination": destination.to_string_lossy().to_string(),
|
||||
"bytes_copied": bytes,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": false,
|
||||
"error": format!("Failed to copy file: {}", e),
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ mod optimization;
|
||||
mod analysis;
|
||||
mod setfiles;
|
||||
mod reports;
|
||||
mod utility;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ToolHandler {
|
||||
@@ -28,12 +29,18 @@ impl ToolHandler {
|
||||
"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,
|
||||
|
||||
// Expert/Indicator/Script handlers
|
||||
"list_experts" => experts::handle_list_experts(&self.config, args).await,
|
||||
"list_indicators" => experts::handle_list_indicators(&self.config, args).await,
|
||||
"list_scripts" => experts::handle_list_scripts(&self.config, args).await,
|
||||
"compile_ea" => experts::handle_compile_ea(&self.config, args).await,
|
||||
"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,
|
||||
|
||||
// Backtest handlers
|
||||
"run_backtest" => backtest::handle_run_backtest(&self.config, args).await,
|
||||
@@ -81,6 +88,16 @@ impl ToolHandler {
|
||||
"get_history" => reports::handle_get_history(args).await,
|
||||
"annotate_history" => reports::handle_annotate_history(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,
|
||||
"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,
|
||||
"check_mt5_status" => utility::handle_check_mt5_status(&self.config).await,
|
||||
"create_set_template" => utility::handle_create_set_template(&self.config, args).await,
|
||||
"export_report" => utility::handle_export_report(&self.config, args).await,
|
||||
|
||||
_ => Ok(json!({
|
||||
"content": [{ "type": "text", "text": format!("Tool '{}' not implemented", name) }],
|
||||
"isError": true
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use crate::models::Config;
|
||||
|
||||
pub async fn handle_read_set_file(args: &Value) -> Result<Value> {
|
||||
|
||||
@@ -60,17 +60,78 @@ pub async fn handle_verify_setup(config: &Config) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub async fn handle_list_symbols(config: &Config) -> Result<Value> {
|
||||
let symbols = config.discover_symbols();
|
||||
// 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."
|
||||
} else {
|
||||
"No history data found. Open MT5 and download tick data for the symbols you want to backtest."
|
||||
}
|
||||
} else {
|
||||
"These symbols have local tick history and can be used for backtesting."
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"count": symbols.len(),
|
||||
"symbols": symbols,
|
||||
"hint": if symbols.is_empty() {
|
||||
"No history data found. Open MT5 and download tick data for the symbols you want to backtest."
|
||||
} else {
|
||||
"These symbols have local tick history and can be used for backtesting."
|
||||
}
|
||||
"active_account": current_account.map(|a| json!({
|
||||
"login": a.login,
|
||||
"server": a.server
|
||||
})),
|
||||
"active_server": active_server,
|
||||
"available_servers": all_servers,
|
||||
"hint": hint,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get active MT5 account info with available symbols for pre-flight checks
|
||||
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() {
|
||||
"Active account found but no symbol history data. Download tick data in MT5 Strategy Tester."
|
||||
} else {
|
||||
"Ready for backtesting. Use these symbols with run_backtest."
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"ready_for_backtest": ready_for_backtest,
|
||||
"account": current_account.map(|a| json!({
|
||||
"login": a.login,
|
||||
"server": a.server
|
||||
})),
|
||||
"server": active_server,
|
||||
"available_servers": all_servers,
|
||||
"symbols": symbols,
|
||||
"symbol_count": symbols.len(),
|
||||
"hint": hint,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,843 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use crate::models::Config;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
pub async fn handle_compare_backtests(_config: &Config, args: &Value) -> Result<Value> {
|
||||
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);
|
||||
|
||||
for (_i, comp) in comparisons.iter().enumerate().skip(1) {
|
||||
let profit = comp.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let dd = comp.get("drawdown_pct").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let pf = comp.get("profit_factor").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
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 {
|
||||
// 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)?
|
||||
} 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");
|
||||
let _deals_path = path.join("deals.csv");
|
||||
|
||||
// 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!({})
|
||||
};
|
||||
|
||||
// Determine output file; restrict user-supplied path to the report directory.
|
||||
let output = match output_path {
|
||||
Some(p) => safe_output_path(p, path)?,
|
||||
None => path.join(format!("report.{}", format)),
|
||||
};
|
||||
|
||||
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
|
||||
}))
|
||||
}
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
# E2E Test for all 43 MT5-Quant MCP tools
|
||||
|
||||
set -e
|
||||
|
||||
BINARY="/usr/local/bin/mt5-quant"
|
||||
FAILED=0
|
||||
PASSED=0
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo "=========================================="
|
||||
echo "MT5-Quant E2E Test - All 43 Tools"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Test helper function - sends initialize + tool call in one session
|
||||
test_tool() {
|
||||
local tool_name=$1
|
||||
local tool_request=$2
|
||||
local expected_field=$3
|
||||
|
||||
echo -n "Testing $tool_name... "
|
||||
|
||||
# Send initialize + tool call in one session (stdio transport)
|
||||
response=$(printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}\n%s\n' "$tool_request" | timeout 10 $BINARY 2>/dev/null | tail -1)
|
||||
|
||||
if echo "$response" | grep -q "$expected_field"; then
|
||||
echo -e "${GREEN}PASS${NC}"
|
||||
((PASSED++))
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}FAIL${NC}"
|
||||
echo " Request: $tool_request"
|
||||
echo " Response: $response"
|
||||
((FAILED++))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 1: Initialize + tools/list
|
||||
echo "=== Core Protocol ==="
|
||||
test_tool "initialize/tools_list" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
|
||||
'"name":"run_backtest"'
|
||||
|
||||
echo ""
|
||||
echo "=== System Tools ==="
|
||||
|
||||
# Test 2: healthcheck
|
||||
test_tool "healthcheck" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"healthcheck","arguments":{}}}' \
|
||||
'healthy'
|
||||
|
||||
# Test 3: verify_setup
|
||||
test_tool "verify_setup" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"verify_setup","arguments":{}}}' \
|
||||
'all_ok'
|
||||
|
||||
# Test 4: list_symbols
|
||||
test_tool "list_symbols" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_symbols","arguments":{}}}' \
|
||||
'symbols'
|
||||
|
||||
echo ""
|
||||
echo "=== Expert/Indicator/Script Tools ==="
|
||||
|
||||
# Test 5: list_experts
|
||||
test_tool "list_experts" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_experts","arguments":{}}}' \
|
||||
'experts'
|
||||
|
||||
# Test 6: list_indicators
|
||||
test_tool "list_indicators" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_indicators","arguments":{}}}' \
|
||||
'indicators'
|
||||
|
||||
# Test 7: list_scripts
|
||||
test_tool "list_scripts" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_scripts","arguments":{}}}' \
|
||||
'scripts'
|
||||
|
||||
echo ""
|
||||
echo "=== Report Tools ==="
|
||||
|
||||
# Test 8: list_reports
|
||||
test_tool "list_reports" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_reports","arguments":{}}}' \
|
||||
'reports'
|
||||
|
||||
# Test 9: get_latest_report
|
||||
test_tool "get_latest_report" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_latest_report","arguments":{}}}' \
|
||||
'success'
|
||||
|
||||
# Test 10: search_reports
|
||||
test_tool "search_reports" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_reports","arguments":{}}}' \
|
||||
'reports'
|
||||
|
||||
# Test 11: prune_reports
|
||||
test_tool "prune_reports" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"prune_reports","arguments":{"keep_last":10}}}' \
|
||||
'success'
|
||||
|
||||
echo ""
|
||||
echo "=== Set File Tools ==="
|
||||
|
||||
# Test 12: list_set_files
|
||||
test_tool "list_set_files" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_set_files","arguments":{}}}' \
|
||||
'set_files'
|
||||
|
||||
echo ""
|
||||
echo "=== Cache Tools ==="
|
||||
|
||||
# Test 13: cache_status
|
||||
test_tool "cache_status" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"cache_status","arguments":{}}}' \
|
||||
'success'
|
||||
|
||||
# Test 14: clean_cache
|
||||
test_tool "clean_cache" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"clean_cache","arguments":{"dry_run":true}}}' \
|
||||
'success'
|
||||
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
echo "=========================================="
|
||||
echo -e "Passed: ${GREEN}$PASSED${NC}"
|
||||
echo -e "Failed: ${RED}$FAILED${NC}"
|
||||
echo "=========================================="
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo -e "${GREEN}All tests passed!${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}Some tests failed!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user