24 Commits

Author SHA1 Message Date
Devid HW a1434914e9 chore: update release workflow and server.json for v1.30.0
- Update server.json: version 1.30.0, new download URL, updated description
- Add MCP package build job to release workflow
- Add SHA256 calculation for server.json
- Update release workflow to upload mcp-* package
2026-04-20 02:51:36 +07:00
Devid HW 6ce8808948 v1.30.0: Add 10 Wine/MT5 debugging tools
New debugging/diagnostics tools for crash investigation:
- diagnose_wine: Check Wine installation and prefix health
- get_mt5_logs: Get terminal/tester/metaeditor logs
- search_mt5_errors: Search logs for error patterns
- check_mt5_process: Check MT5 process status
- kill_mt5_process: Kill stuck MT5 processes
- check_system_resources: Check disk/memory/CPU
- validate_mt5_config: Validate MT5 configuration
- get_wine_prefix_info: Wine prefix details
- get_backtest_crash_info: Investigate backtest failures

Total tools: 85
Documentation updated in README.md and MCP_TOOLS.md
2026-04-20 02:25:07 +07:00
Devid HW 896aa6111e feat: auto-detect and register MCP on multiple platforms
- Add multi-platform MCP detection (Claude, Windsurf, Cursor, VS Code)
- Auto-unregister previous installations before reinstall
- Create platform-specific docs (CURSOR.md, VSCODE.md, ANTIGRAVITY.md)
- Update Windsurf config to use correct JSON path (~/.codeium/windsurf/mcp_config.json)
- Fix VS Code config format (uses 'servers' not 'mcpServers')
- Remove MT5_MCP_HOME requirement from MCP configs (binary auto-detects)
- Slim down README installation section with platform table
- Update QUICKSTART.md with all platform instructions
2026-04-20 00:40:41 +07:00
Devid aab34a1fde Add GitHub Actions workflow for Rust project 2026-04-19 10:16:03 +07:00
Devid HW 96c7e0a9c2 chore: update server.json for v1.29.1 release 2026-04-19 10:10:55 +07:00
Devid HW 0c82501e18 chore: bump version to 1.29.1 2026-04-19 10:10:02 +07:00
Devid HW 2fef0db3c4 fix: wrap tools/list response in {tools: [...]} per MCP spec 2026-04-19 10:07:59 +07:00
Devid HW 95ceef3e3f chore: update Cargo.lock for v1.29.0 2026-04-19 09:54:55 +07:00
Devid HW 662e9e324c chore: update server.json for v1.29.0 release 2026-04-19 09:53:18 +07:00
Devid HW 6d1f940887 chore: bump version to 1.29.0 2026-04-19 09:52:15 +07:00
Devid HW 41c8b36826 fix: handle MCP notifications properly (notifications/initialized)
- Add handle_notification() for notification messages (no id field)
- Use CARGO_PKG_VERSION for dynamic version in initialize response
- Skip serializing None fields in McpResponse and McpError
- Remove verified field from setup info
2026-04-19 09:51:46 +07:00
Devid HW 42318b040a docs: update README with Claude Code and Windsurf MCP installation instructions
- Separate download/setup and MCP registration steps
- Add detailed Claude Code registration with verification
- Add Windsurf config.yaml examples (manual and automated)
- Add absolute path requirement warning note
2026-04-19 09:45:47 +07:00
Devid HW fdb45b8eed fix: redirect tracing logs to stderr to keep stdout clean for MCP JSON
tracing_subscriber::fmt::init() defaults to stdout, contaminating the
MCP JSON-RPC stream and causing Claude Code and Windsurf to fail the
initialize handshake with "failed to reconnect".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 09:21:00 +07:00
Devid HW b63bda64ab chore: Update server.json for v1.28.0 release 2026-04-19 08:49:18 +07:00
Devid HW cbed3d15af docs: update README and MCP_TOOLS.md for 57 tools
README.md:
- Update tool count: 43 → 57
- Add Pre-flight & Validation section with get_active_account, check_symbol_data_status, check_mt5_status, validate_ea_syntax
- Add Project Management section with init_project, create_set_template, export_report
- Add History & Comparison section with get_backtest_history, compare_backtests
- Add Search & Discovery section with search_experts, search_indicators, search_scripts, copy_indicator_to_project, copy_script_to_project

MCP_TOOLS.md:
- Update documentation status: 31 → 49 of 57 tools documented
- Add 8 new utility tool schemas:
  - get_active_account
  - check_symbol_data_status
  - check_mt5_status
  - get_backtest_history
  - compare_backtests
  - init_project
  - validate_ea_syntax
  - create_set_template
  - export_report
- Add workflow examples for pre-flight validation, project management, and history comparison
2026-04-19 08:28:07 +07:00
Devid HW 8cce92c409 chore: update server.json to v1.28.0 with new release URL and SHA256
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 08:20:43 +07:00
Devid HW 2b136b845b fix: patch path traversal in export_report and create_set_template (v1.28.0)
Both handlers accepted a user-supplied output_path without boundary
validation, allowing MCP clients to write files to arbitrary filesystem
locations. A new safe_output_path() helper canonicalizes the parent
directory and asserts the resolved path stays within the allowed base
directory before any fs::write() call.

Also fixes pre-existing unused-import/dead-code warnings that blocked
the -D warnings release build.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 08:19:45 +07:00
Devid HW d41c51bae4 feat: add get_active_account and 8 new utility tools
High Priority Tools:
- check_symbol_data_status: validate symbol history data availability
- get_backtest_history: list all backtest results for EA/symbol
- compare_backtests: side-by-side comparison of multiple backtests

Medium Priority Tools:
- init_project: scaffold new MQL5 project with templates (scalper/swing/grid)
- validate_ea_syntax: pre-compile syntax checking
- check_mt5_status: check MT5 terminal readiness

Low Priority Tools:
- create_set_template: generate .set files from EA inputs
- export_report: export reports to CSV/JSON/Markdown

Other Changes:
- Consolidate config into src/models/config.rs, delete src/config.rs
- Add CurrentAccount with UTF-16LE parsing for common.ini
- Add BacktestPreflight struct for pre-flight checks
- Update backtest handler with active account context
- Update list_symbols to filter by active server

Total tools: 57
2026-04-19 08:11:17 +07:00
Devid HW eae612df75 chore: update server.json to v1.27.0 with new release URL and SHA256 2026-04-19 06:13:22 +07:00
Devid HW 94eb8927fd fix: 0 warnings build and rename package to mcp-mt5-quant 2026-04-19 06:11:43 +07:00
Devid HW 2104fabe50 fix: correct WINDSURF.md path in build script 2026-04-19 06:09:35 +07:00
Devid HW 51f19640c3 fix: use official MCP schema URL and add VS Code settings for schema validation 2026-04-19 06:07:26 +07:00
Devid HW 1ec0adb6e6 perf: Make auto-verify non-blocking to prevent health check timeout 2026-04-19 06:06:05 +07:00
Devid HW 397f893bfc feat: Auto-verify setup on first initialization
- Add auto_verify_result field to McpServer
- Run verify_setup automatically during initialize
- Include setup status in serverInfo response:
  - verified: bool
  - hint: string message
- Users immediately see if environment is ready
2026-04-19 05:58:16 +07:00
37 changed files with 6840 additions and 265 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Rust
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
+8
View File
@@ -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
View File
@@ -481,7 +481,7 @@ dependencies = [
[[package]]
name = "mt5-quant"
version = "1.27.0"
version = "1.30.0"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mt5-quant"
version = "1.27.0"
version = "1.30.0"
edition = "2021"
description = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP"
authors = ["masdevid <masdevid@example.com>"]
+98 -8
View File
@@ -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.** 85 tools to compile, backtest, analyze, optimize, debug crashes, and manage MQL5 Expert Advisors — no Windows required.
```
You: "Backtest MyEA Jan-Mar, what caused the February drawdown?"
@@ -20,17 +20,29 @@ Claude: [compile → clean → backtest → analyze 1,847 deals]
| MQL5 compilation | ✅ | ❌ | ❌ |
| macOS/Linux native | ✅ | Windows only | Cloud |
| Optimization | ✅ Background | ❌ | ✅ Paid |
| Crash debugging | ✅ Wine/MT5 diagnostics | ❌ | ❌ |
## Quick Install
### 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
claude mcp add MT5-Quant -- $(pwd)/mt5-quant
```
**[Full Setup →](docs/QUICKSTART.md)**
### 2. Register MCP Server
| Platform | Command / Config | Docs |
|----------|------------------|------|
| **Claude Code** | `claude mcp add mt5-quant -- $(pwd)/mt5-quant` | [Setup →](docs/QUICKSTART.md) |
| **Windsurf** | Edit `~/.codeium/windsurf/mcp_config.json` | [WINDSURF.md →](docs/WINDSURF.md) |
| **Cursor** | Edit `~/.cursor/mcp.json` or use Settings → MCP | [CURSOR.md →](docs/CURSOR.md) |
| **VS Code** | Edit `.vscode/mcp.json` or run `MCP: Add Server` | [VSCODE.md →](docs/VSCODE.md) |
| **Antigravity** | Agent Panel → ... → MCP Servers → Edit configuration | [ANTIGRAVITY.md →](docs/ANTIGRAVITY.md) |
> **Note:** Use absolute paths like `/Users/name/mt5-quant/mt5-quant` or `$(pwd)/mt5-quant`, not relative paths like `./mt5-quant`.
## Quick Start
@@ -45,14 +57,17 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
| Doc | Purpose |
|-----|---------|
| [QUICKSTART.md](docs/QUICKSTART.md) | Complete setup for macOS/Linux |
| [WINDSURF.md](docs/WINDSURF.md) | Windsurf IDE setup |
| [CURSOR.md](docs/CURSOR.md) | Cursor IDE setup |
| [VSCODE.md](docs/VSCODE.md) | VS Code setup |
| [ANTIGRAVITY.md](docs/ANTIGRAVITY.md) | Antigravity IDE setup |
| [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 75 tools documented |
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design and internals |
| [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Common issues |
| [REMOTE_AGENTS.md](docs/REMOTE_AGENTS.md) | Linux optimization agents |
## MCP Tools (43)
## MCP Tools (75)
### Core workflow
@@ -84,6 +99,21 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
Use these for targeted analysis, or `analyze_report` to run all at once.
### Deal-Level Analytics (New)
| Tool | Description |
|------|-------------|
| `list_deals` | List individual deals with filters (type, profit range, volume, dates) |
| `search_deals_by_comment` | Full-text search in deal comments (e.g., "Layer #3") |
| `search_deals_by_magic` | Filter deals by EA magic number |
| `analyze_profit_distribution` | Profit histogram: small/medium/large wins and losses |
| `analyze_time_performance` | Performance by hour of day and day of week |
| `analyze_hold_time_distribution` | Hold time buckets + correlation with profit |
| `analyze_layer_performance` | Grid/martingale layer analysis from comments |
| `analyze_volume_vs_profit` | Volume correlation + performance by lot size |
| `analyze_costs` | Commission and swap impact on profitability |
| `analyze_efficiency` | Profit per hour/day, annualized return, trade frequency |
### Monitoring
| Tool | Description |
@@ -100,6 +130,14 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
| `list_reports` | Compact table of all runs with key metrics — no full analysis needed |
| `get_latest_report` | Get most recent report with optional equity chart |
| `search_reports` | Find reports by EA, symbol, date range, or profit criteria |
| `get_report_by_id` | Get specific report by ID with equity chart |
| `get_reports_summary` | Aggregate stats: counts, averages, pass rates |
| `get_best_reports` | Top N reports sorted by any metric (profit factor, drawdown, etc.) |
| `search_reports_by_tags` | Find reports by tags |
| `search_reports_by_date_range` | Query by backtest date range |
| `search_reports_by_notes` | Full-text search in report notes |
| `get_reports_by_set_file` | Find all reports using a specific .set file |
| `get_comparable_reports` | Find comparable reports (same EA/symbol/timeframe) |
| `tail_log` | Read last N lines of any log; `filter=errors` to see only failures |
| `prune_reports` | Delete old report directories, keep last N (skips `_opt` dirs) |
@@ -120,6 +158,44 @@ 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 |
### Debugging & Diagnostics (New)
| Tool | Description |
|------|-------------|
| `diagnose_wine` | Check Wine installation, version, and prefix health |
| `get_mt5_logs` | Get MT5 terminal, tester, or MetaEditor logs with filtering |
| `search_mt5_errors` | Search logs for error patterns (crash, exception, access violation) |
| `check_mt5_process` | Check if MT5 processes are running, get PID, CPU, memory usage |
| `kill_mt5_process` | Kill stuck MT5 processes (force=true for wineserver) |
| `check_system_resources` | Check disk space, memory, CPU availability |
| `validate_mt5_config` | Validate terminal.ini and tester configuration files |
| `get_wine_prefix_info` | Get Wine prefix details: Windows version, installed programs, registry |
| `get_backtest_crash_info` | Investigate backtest failures: incomplete markers, missing deals.csv, errors |
### Project Management
| Tool | Description |
|------|-------------|
| `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,12 +214,28 @@ 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
Run `verify_setup` from Claude first — it checks all paths and returns actionable hints.
For crashes or unexplained failures during backtest/compile/optimization:
- `diagnose_wine` — Check Wine installation and prefix health
- `search_mt5_errors` — Find crash causes in logs
- `check_mt5_process` + `kill_mt5_process` — Detect and kill stuck processes
- `get_backtest_crash_info` — Investigate failed backtest reports
**[Full Troubleshooting Guide →](docs/TROUBLESHOOTING.md)**
---
@@ -153,5 +245,3 @@ Run `verify_setup` from Claude first — it checks all paths and returns actiona
MIT
---
*Built from battle-tested production infrastructure. Every edge case in the pipeline was hit in production.*
+145
View File
@@ -0,0 +1,145 @@
# Antigravity MCP Integration Setup
## Quick Setup
### Option 1: Download Prebuilt Binary (Recommended)
```bash
# macOS (Apple Silicon)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 2: Build from Source
```bash
cargo build --release
```
## Configure Antigravity
### Step 1: Open MCP Manager
1. Launch Antigravity
2. Look at the **right-side Agent Panel**
3. Click the **"..."** (More Options) menu at the top
4. Select **MCP Servers**
### Step 2: Access Configuration
1. Click **Manage MCP Servers**
2. Click **View raw config** or **Edit configuration**
3. This opens `mcp_config.json`
### Step 3: Add mt5-quant Configuration
Add to `mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/absolute/path/to/mt5-quant"
}
}
}
```
### Step 4: Reload and Verify
1. Save the file
2. **Restart Antigravity** (or reload the window)
3. Open the Agent chat and type: `What tools do you have access to?`
4. The agent should list MT5 tools like `verify_setup`, `run_backtest`, etc.
## Full Example Configuration
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/name/mt5-quant/target/release/mt5-quant"
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${env:GITHUB_TOKEN}"
}
}
}
}
```
## Environment Variables
Antigravity supports `${VAR_NAME}` syntax for environment variable substitution:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/name/mt5-quant/target/release/mt5-quant",
"env": {
"CUSTOM_VAR": "${env:MY_VAR}"
}
}
}
}
```
This keeps secrets out of the config file.
## Verify Setup
In Antigravity chat, type:
```
Run verify_setup
```
Expected output:
```
Wine: /Applications/MetaTrader 5.app/.../wine64
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
Display: gui
Arch: arch -x86_64
```
## Troubleshooting
### "Connection Refused" or "Tool not found"
1. Double-check the server path in `mcp_config.json`
2. Ensure the binary has execute permissions: `chmod +x /path/to/mt5-quant`
3. Try completely restarting Antigravity
4. Check the server is listed in MCP manager
### "Stdio Error" or JSON Parsing Error
1. Verify the JSON syntax in `mcp_config.json`
2. Use a JSON validator if needed
3. Ensure no trailing commas
### Agent Hallucinating Tool Parameters
If the agent makes up incorrect parameters:
1. Check the tool is actually available: "List your available tools"
2. Restart the agent session
3. Be explicit in your requests
## Configuration Location
| Platform | Path |
|----------|------|
| All | Via UI: Agent Panel → ... → MCP Servers → Manage → Edit configuration |
## Resources
- [Antigravity Documentation](https://docs.antigravity.dev/)
- [MCP Server Reference](https://github.com/modelcontextprotocol/servers)
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
+129
View File
@@ -0,0 +1,129 @@
# Cursor MCP Integration Setup
## Quick Setup
### Option 1: Download Prebuilt Binary (Recommended)
```bash
# macOS (Apple Silicon)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 2: Build from Source
```bash
cargo build --release
```
## Configure Cursor
### Method 1: Settings UI (Recommended)
1. Open Cursor Settings (`Cmd/Ctrl + ,`)
2. Navigate to **Features****MCP**
3. Click **Add Custom MCP**
4. Enter:
- **Name**: `mt5-quant`
- **Command**: Full path to binary (e.g., `/Users/name/mt5-quant/target/release/mt5-quant`)
- **Type**: `stdio`
### Method 2: Edit mcp.json Directly
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
```json
{
"mcpServers": {
"mt5-quant": {
"type": "stdio",
"command": "/absolute/path/to/mt5-quant"
}
}
}
```
Create the file if it doesn't exist:
```bash
mkdir -p ~/.cursor
cat > ~/.cursor/mcp.json << 'EOF'
{
"mcpServers": {
"mt5-quant": {
"type": "stdio",
"command": "/path/to/mt5-quant"
}
}
}
EOF
```
## Verify Setup
In Cursor chat, type:
```
Run verify_setup
```
Expected output:
```
Wine: /Applications/MetaTrader 5.app/.../wine64
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
Display: gui
Arch: arch -x86_64
```
## Configuration Locations
| Scope | Path | Use Case |
|-------|------|----------|
| Global | `~/.cursor/mcp.json` | Available in all projects |
| Project | `.cursor/mcp.json` | Project-specific tools |
## Troubleshooting
### MCP server not appearing
1. Check MCP panel in Cursor Settings
2. Verify the path is absolute (not relative)
3. Test binary: `/path/to/mt5-quant --help`
4. View MCP logs: Output panel → select "MCP" from dropdown
### Config interpolation
Cursor supports variable substitution in `mcp.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "${userHome}/bin/mt5-quant",
"args": ["--config", "${workspaceFolder}/config.yaml"]
}
}
}
```
Available variables:
- `${userHome}` - Home directory
- `${workspaceFolder}` - Project root
- `${workspaceFolderBasename}` - Project folder name
- `${env:VAR_NAME}` - Environment variable
### Tool not found errors
If the agent says "Tool not found":
1. Check the server is enabled in MCP settings
2. Try disabling and re-enabling the server
3. Restart Cursor
## Resources
- [Cursor MCP Documentation](https://cursor.com/docs/context/mcp)
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
+349 -3
View File
@@ -2,11 +2,15 @@
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 85 total tools. Missing:
> - `list_experts`, `list_indicators`, `list_scripts`
> - `healthcheck`
> - `search_reports`, `get_latest_report`
> - `healthcheck`, `list_symbols`
> - Reports query: `search_reports`, `get_latest_report`, `list_reports`, `prune_reports`, `tail_log`, `get_report_by_id`, `get_reports_summary`, `get_best_reports`, `search_reports_by_tags`, `search_reports_by_date_range`, `search_reports_by_notes`, `get_reports_by_set_file`, `get_comparable_reports`
> - Granular analytics: `analyze_monthly_pnl`, `analyze_drawdown_events`, `analyze_top_losses`, `analyze_loss_sequences`, `analyze_position_pairs`, `analyze_direction_bias`, `analyze_streaks`, `analyze_concurrent_peak`
> - Deal analytics: `list_deals`, `search_deals_by_comment`, `search_deals_by_magic`, `analyze_profit_distribution`, `analyze_time_performance`, `analyze_hold_time_distribution`, `analyze_layer_performance`, `analyze_volume_vs_profit`, `analyze_costs`, `analyze_efficiency`
> - Archive/history tools: `archive_report`, `archive_all_reports`, `get_history`, `annotate_history`, `promote_to_baseline`
> - Experts search: `search_experts`, `search_indicators`, `search_scripts`, `copy_indicator_to_project`, `copy_script_to_project`
> - Debugging/diagnostics: `diagnose_wine`, `get_mt5_logs`, `search_mt5_errors`, `check_mt5_process`, `kill_mt5_process`, `check_system_resources`, `validate_mt5_config`, `get_wine_prefix_info`, `get_backtest_crash_info`
---
@@ -1237,6 +1241,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 +1848,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
```
+60 -7
View File
@@ -91,15 +91,68 @@ claude mcp list
### Windsurf
Add to `~/.windsurf/config.yaml`:
```yaml
mcpServers:
mt5-quant:
command: /path/to/mt5-quant
env:
MT5_MCP_HOME: /path/to/mt5-mcp
Add to `~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
```
### Cursor
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
```
Or use Settings → MCP → Add Custom MCP.
### VS Code
Add to `.vscode/mcp.json` in your workspace:
```json
{
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
```
Or run `MCP: Add Server` from Command Palette.
### Antigravity
1. Open Agent panel → Click "..." menu → MCP Servers
2. Click "Manage MCP Servers"
3. Click "View raw config" or "Edit configuration"
4. Add to `mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
```
5. Reload Antigravity to apply changes
## 5. Verify Setup
```bash
+147
View File
@@ -0,0 +1,147 @@
# VS Code MCP Integration Setup
## Quick Setup
### Option 1: Download Prebuilt Binary (Recommended)
```bash
# macOS (Apple Silicon)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 2: Build from Source
```bash
cargo build --release
```
## Configure VS Code
### Method 1: Command Palette (Recommended)
1. Press `Cmd/Ctrl + Shift + P`
2. Run `MCP: Add Server`
3. Choose **Workspace** or **User** scope
4. Enter server name: `mt5-quant`
5. Enter command: Full path to binary (e.g., `/Users/name/mt5-quant/target/release/mt5-quant`)
### Method 2: Edit mcp.json Directly
Add to `.vscode/mcp.json` in your workspace:
```json
{
"servers": {
"mt5-quant": {
"command": "/absolute/path/to/mt5-quant"
}
}
}
```
Create the file:
```bash
mkdir -p .vscode
cat > .vscode/mcp.json << 'EOF'
{
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
EOF
```
### Method 3: VS Code CLI
```bash
code --add-mcp '{"name":"mt5-quant","command":"/path/to/mt5-quant"}'
```
## Verify Setup
In Copilot chat, type:
```
Run verify_setup
```
Expected output:
```
Wine: /Applications/MetaTrader 5.app/.../wine64
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
Display: gui
Arch: arch -x86_64
```
## Configuration Locations
| Scope | Path | Use Case |
|-------|------|----------|
| Workspace | `.vscode/mcp.json` | Share with team via source control |
| User | `~/.vscode/mcp.json` | Personal tools across all projects |
| Dev Container | `devcontainer.json``customizations.vscode.mcp` | Containerized environments |
## Troubleshooting
### MCP server not appearing
1. Open **Output** panel (`Cmd/Ctrl + Shift + U`)
2. Select **MCP** from dropdown
3. Check for connection errors
4. Verify the path is absolute
### Config not found
The binary auto-detects its config, but you can also:
1. Run `setup.sh` to create `config/mt5-quant.yaml`
2. Or let the binary auto-discover on first run
### Dev Container Setup
Add to `.devcontainer/devcontainer.json`:
```json
{
"customizations": {
"vscode": {
"mcp": {
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
}
}
}
```
## Key Differences from Other IDEs
VS Code uses `servers` (not `mcpServers`) in the JSON structure:
```json
{
"servers": { // ← VS Code uses "servers"
"mt5-quant": {
"command": "..."
}
}
}
```
Other platforms use `mcpServers`.
## Resources
- [VS Code MCP Documentation](https://code.visualstudio.com/docs/copilot/customization/mcp-servers)
- [MCP Configuration Reference](https://code.visualstudio.com/docs/copilot/reference/mcp-configuration)
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
+26 -11
View File
@@ -22,14 +22,22 @@ bash scripts/build-rust.sh
### 2. Configure Windsurf
Edit `~/.windsurf/config.yaml`:
Edit `~/.codeium/windsurf/mcp_config.json`:
```yaml
mcpServers:
mt5-quant:
command: /Users/masdevid/jobs/mt5-quant/target/release/mt5-quant
env:
MT5_MCP_HOME: /Users/masdevid/jobs/mt5-quant
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/masdevid/jobs/mt5-quant/target/release/mt5-quant"
}
}
}
```
Or use the automated setup:
```bash
bash scripts/setup.sh
```
### 3. Restart Windsurf
@@ -66,12 +74,19 @@ scp -r config/mt5-quant.yaml user@server:~/.config/mt5-quant/config/
- **NO Python required!**
### Windsurf Config on Target Machine
```yaml
mcpServers:
mt5-quant:
command: /usr/local/bin/mt5-quant
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/usr/local/bin/mt5-quant"
}
}
}
```
The binary auto-detects its config location. No environment variables needed.
## Troubleshooting
### MCP server not appearing
+7 -5
View File
@@ -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"
+460 -29
View File
@@ -7,6 +7,16 @@
# --keep-last N Keep only last N backtest reports (default: 20)
# --claude-code Generate CLAUDE.md template and .claude/hooks/user-prompt-submit.sh
# (skips main config wizard — run standalone or alongside normal setup)
#
# MCP Auto-Registration (per official 2025 docs):
# Automatically detects and registers with available MCP platforms:
# - Claude Code : via 'claude mcp add' (stored in ~/.claude.json)
# - Windsurf : ~/.codeium/windsurf/mcp_config.json (JSON, mcpServers)
# - Cursor : ~/.cursor/mcp.json (JSON, mcpServers)
# - VS Code : .vscode/mcp.json (JSON, servers - not mcpServers)
# - Antigravity : mcp_config.json via UI (JSON, mcpServers)
#
# Previous installations are auto-detected and uninstalled before re-registering.
set -uo pipefail
@@ -648,6 +658,30 @@ main() {
_bold "MT5-Quant setup — auto-detecting Wine and MT5 paths"
echo "────────────────────────────────────────────────────"
# ── Check for previous installation ────────────────────────────────────────
if _check_any_existing_registration; then
_yellow "Previous mt5-quant MCP installation detected on one or more platforms"
echo " New path: ${REPO_DIR}/server/main.py"
local reinstall_ans="yes"
if ! $AUTO_YES; then
reinstall_ans=$(_ask "Unregister all previous installations and reinstall?" "yes")
fi
if [[ "$reinstall_ans" =~ ^[Yy] ]]; then
_unregister_all_platforms || {
if ! $AUTO_YES; then
local force_ans
force_ans=$(_ask "Some platforms failed to unregister. Continue anyway?" "no")
[[ ! "$force_ans" =~ ^[Yy] ]] && exit 1
fi
}
else
echo "Aborted — keeping existing installations."
exit 0
fi
fi
# ── Check if config already exists ───────────────────────────────────────
if [[ -f "$CONFIG_OUT" ]] && ! $AUTO_YES; then
_yellow "Config already exists: $CONFIG_OUT"
@@ -784,49 +818,446 @@ main() {
_ok "Written: $CONFIG_OUT"
echo " Tip: see config/example.set for optimization .set file format"
# ── Register with Claude Code ──────────────────────────────────────────────
_offer_mcp_register
# ── Register with all detected MCP platforms ─────────────────────────────
_register_all_mcp_platforms
echo ""
_green "Setup complete!"
echo ""
}
# ── MCP registration ──────────────────────────────────────────────────────────
_offer_mcp_register() {
echo ""
_bold "Registering with Claude Code..."
# ── MCP Platform Detection & Registration ───────────────────────────────────
if ! command -v claude &>/dev/null; then
_warn "claude CLI not found — register manually:"
# Detect available MCP platforms and return them as a list
detect_mcp_platforms() {
local platforms=()
# Claude Code
if command -v claude &>/dev/null; then
platforms+=("claude")
fi
# Windsurf (uses ~/.codeium/windsurf/mcp_config.json)
if [[ -d "$HOME/.codeium/windsurf" ]] || [[ -d "$HOME/.windsurf" ]] || command -v windsurf &>/dev/null; then
platforms+=("windsurf")
fi
# Cursor (uses ~/.cursor/mcp.json)
if [[ -d "$HOME/.cursor" ]] || [[ -d "$HOME/Library/Application Support/Cursor" ]] || command -v cursor &>/dev/null; then
platforms+=("cursor")
fi
# VS Code (uses .vscode/mcp.json or user profile)
if [[ -d "$HOME/.vscode" ]] || [[ -d "$HOME/Library/Application Support/Code" ]] || command -v code &>/dev/null; then
platforms+=("vscode")
fi
printf '%s\n' "${platforms[@]:-}"
}
# Check if mt5-quant is registered on a specific platform
_is_registered_on_platform() {
local platform="$1"
case "$platform" in
claude)
if command -v claude &>/dev/null; then
local mcp_list
mcp_list=$(claude mcp list 2>/dev/null || true)
echo "$mcp_list" | grep -q "mt5-quant"
return $?
fi
return 1
;;
windsurf)
local config_file="$HOME/.codeium/windsurf/mcp_config.json"
[[ -f "$config_file" ]] && grep -q '"mt5-quant"' "$config_file" 2>/dev/null
return $?
;;
cursor)
local config_file
config_file="$HOME/.cursor/mcp.json"
[[ -f "$config_file" ]] && grep -q "mt5-quant" "$config_file" 2>/dev/null
return $?
;;
vscode)
# VS Code uses .vscode/mcp.json in workspace or user profile
local workspace_config=".vscode/mcp.json"
local user_config
user_config="$HOME/.vscode/mcp.json"
[[ -f "$workspace_config" ]] && grep -q '"mt5-quant"' "$workspace_config" 2>/dev/null && return 0
[[ -f "$user_config" ]] && grep -q '"mt5-quant"' "$user_config" 2>/dev/null && return 0
return 1
;;
esac
return 1
}
# Get registered path for a platform
_get_platform_mcp_path() {
local platform="$1"
case "$platform" in
claude)
if command -v claude &>/dev/null; then
local mcp_list
mcp_list=$(claude mcp list 2>/dev/null || true)
echo "$mcp_list" | grep "mt5-quant" | head -1 | sed -E 's/.*--[[:space:]]*//' | tr -d ' '
fi
;;
windsurf)
local config_file="$HOME/.codeium/windsurf/mcp_config.json"
if [[ -f "$config_file" ]]; then
grep -A3 '"mt5-quant"' "$config_file" 2>/dev/null | grep '"command"' | sed 's/.*"command":[[:space:]]*"\([^"]*\)".*/\1/'
fi
;;
cursor)
local config_file="$HOME/.cursor/mcp.json"
if [[ -f "$config_file" ]]; then
grep -A3 '"mt5-quant"' "$config_file" 2>/dev/null | grep '"command"' | sed 's/.*"command":[[:space:]]*"\([^"]*\)".*/\1/'
fi
;;
esac
}
# Unregister from a specific platform
_unregister_from_platform() {
local platform="$1"
echo ""
_bold "Unregistering from $platform..."
case "$platform" in
claude)
if ! command -v claude &>/dev/null; then
_warn "claude CLI not found"
return 1
fi
local out
out=$(claude mcp remove mt5-quant 2>&1) || true
if echo "$out" | grep -qi "removed\|success\|deleted"; then
_ok "Unregistered from Claude Code"
return 0
elif echo "$out" | grep -qi "not found\|does not exist"; then
_ok "No existing registration on Claude Code"
return 0
else
_warn "Unregister result: $out"
return 1
fi
;;
windsurf)
local config_file="$HOME/.codeium/windsurf/mcp_config.json"
if [[ -f "$config_file" ]]; then
# Remove mt5-quant from JSON using Python
if command -v python3 &>/dev/null; then
python3 -c "
import json, sys
with open('$config_file') as f:
data = json.load(f)
if 'mcpServers' in data and 'mt5-quant' in data['mcpServers']:
del data['mcpServers']['mt5-quant']
with open('$config_file', 'w') as f:
json.dump(data, f, indent=2)
" && { _ok "Unregistered from Windsurf"; return 0; }
fi
_warn "Could not auto-unregister from Windsurf — edit $config_file manually"
return 1
fi
_ok "No existing registration on Windsurf"
return 0
;;
cursor)
local config_file="$HOME/.cursor/mcp.json"
if [[ -f "$config_file" ]]; then
# Remove mt5-quant from JSON using Python if available, or sed as fallback
if command -v python3 &>/dev/null; then
python3 -c "
import json, sys
with open('$config_file') as f:
data = json.load(f)
if 'mcpServers' in data and 'mt5-quant' in data['mcpServers']:
del data['mcpServers']['mt5-quant']
with open('$config_file', 'w') as f:
json.dump(data, f, indent=2)
" && { _ok "Unregistered from Cursor"; return 0; }
fi
_warn "Could not auto-unregister from Cursor — edit $config_file manually"
return 1
fi
_ok "No existing registration on Cursor"
return 0
;;
vscode)
# VS Code uses 'servers' (not 'mcpServers') in mcp.json
local workspace_config=".vscode/mcp.json"
local user_config="$HOME/.vscode/mcp.json"
local config_file=""
# Determine which config file to use
if [[ -f "$workspace_config" ]] && grep -q '"mt5-quant"' "$workspace_config" 2>/dev/null; then
config_file="$workspace_config"
elif [[ -f "$user_config" ]] && grep -q '"mt5-quant"' "$user_config" 2>/dev/null; then
config_file="$user_config"
fi
if [[ -n "$config_file" ]]; then
if command -v python3 &>/dev/null; then
python3 -c "
import json, sys
with open('$config_file') as f:
data = json.load(f)
if 'servers' in data and 'mt5-quant' in data['servers']:
del data['servers']['mt5-quant']
with open('$config_file', 'w') as f:
json.dump(data, f, indent=2)
" && { _ok "Unregistered from VS Code ($config_file)"; return 0; }
fi
_warn "Could not auto-unregister from VS Code — edit $config_file manually"
return 1
fi
_ok "No existing registration on VS Code"
return 0
;;
esac
}
# Register with a specific platform
_register_with_platform() {
local platform="$1"
local use_binary="${2:-false}"
# Determine command path (binary for Windsurf/Cursor, Python for Claude)
local cmd_path
if $use_binary && [[ -f "${REPO_DIR}/target/release/mt5-quant" ]]; then
cmd_path="${REPO_DIR}/target/release/mt5-quant"
elif [[ -f "${REPO_DIR}/mt5-quant" ]]; then
cmd_path="${REPO_DIR}/mt5-quant"
else
cmd_path="python3 ${REPO_DIR}/server/main.py"
fi
case "$platform" in
claude)
local out
out=$(claude mcp add mt5-quant -- $cmd_path 2>&1) || true
if echo "$out" | grep -qi "already\|exists"; then
_ok "Already registered on Claude Code"
elif echo "$out" | grep -qi "error\|failed"; then
_warn "Claude Code registration failed: $out"
return 1
else
_ok "Registered on Claude Code"
fi
;;
windsurf)
local config_file="$HOME/.codeium/windsurf/mcp_config.json"
mkdir -p "$(dirname "$config_file")"
# Create or update JSON config
if command -v python3 &>/dev/null; then
python3 -c "
import json, os
config_path = '$config_file'
data = {'mcpServers': {}}
if os.path.exists(config_path):
try:
with open(config_path) as f:
data = json.load(f)
except:
pass
if 'mcpServers' not in data:
data['mcpServers'] = {}
data['mcpServers']['mt5-quant'] = {
'command': '$cmd_path'
}
with open(config_path, 'w') as f:
json.dump(data, f, indent=2)
" && { _ok "Registered on Windsurf ($config_file)"; return 0; }
else
_warn "Python3 required for Windsurf registration"
return 1
fi
;;
cursor)
local config_file="$HOME/.cursor/mcp.json"
mkdir -p "$(dirname "$config_file")"
# Create or update JSON config
if command -v python3 &>/dev/null; then
python3 -c "
import json, os
config_path = '$config_file'
data = {'mcpServers': {}}
if os.path.exists(config_path):
try:
with open(config_path) as f:
data = json.load(f)
except:
pass
if 'mcpServers' not in data:
data['mcpServers'] = {}
data['mcpServers']['mt5-quant'] = {
'command': '$cmd_path'
}
with open(config_path, 'w') as f:
json.dump(data, f, indent=2)
" && { _ok "Registered on Cursor ($config_file)"; return 0; }
else
_warn "Python3 required for Cursor registration"
return 1
fi
;;
vscode)
# VS Code uses .vscode/mcp.json (workspace) or user profile
# Format: { "servers": { "name": { "command": "...", "args": [...], "env": {...} } }
local workspace_config=".vscode/mcp.json"
mkdir -p ".vscode"
if command -v python3 &>/dev/null; then
python3 -c "
import json, os
config_path = '$workspace_config'
data = {'servers': {}}
if os.path.exists(config_path):
try:
with open(config_path) as f:
data = json.load(f)
except:
pass
if 'servers' not in data:
data['servers'] = {}
# For stdio servers, VS Code uses 'command' and optional 'args'
data['servers']['mt5-quant'] = {
'command': '$cmd_path'
}
with open(config_path, 'w') as f:
json.dump(data, f, indent=2)
" && { _ok "Registered on VS Code ($workspace_config)"; return 0; }
else
_warn "Python3 required for VS Code registration"
return 1
fi
;;
esac
}
# Check for any existing registrations across all platforms
_check_any_existing_registration() {
local platforms=()
while IFS= read -r platform; do
[[ -n "$platform" ]] && platforms+=("$platform")
done < <(detect_mcp_platforms)
local found=false
for platform in "${platforms[@]}"; do
if _is_registered_on_platform "$platform"; then
found=true
break
fi
done
$found
}
# Unregister from all platforms
_unregister_all_platforms() {
local platforms=()
while IFS= read -r platform; do
[[ -n "$platform" ]] && platforms+=("$platform")
done < <(detect_mcp_platforms)
for platform in "${platforms[@]}"; do
_is_registered_on_platform "$platform" && _unregister_from_platform "$platform"
done
}
# Main registration function - detects platforms and registers with all
_register_all_mcp_platforms() {
echo ""
_bold "Detecting MCP platforms..."
local platforms=()
while IFS= read -r platform; do
[[ -n "$platform" ]] && platforms+=("$platform")
done < <(detect_mcp_platforms)
if [[ ${#platforms[@]} -eq 0 ]]; then
_warn "No MCP platforms detected (Claude, Windsurf, Cursor, VS Code)"
echo ""
echo " claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
echo " Manual registration required:"
echo " - Claude Code: claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
echo " - Windsurf: Edit ~/.codeium/windsurf/mcp_config.json"
echo " - Cursor: Edit ~/.cursor/mcp.json"
echo " - VS Code: Edit .vscode/mcp.json (workspace) or use MCP: Add Server command"
echo " - Antigravity:Use Agent panel → MCP Servers → Manage → Edit configuration"
echo ""
return
fi
local register=true
if ! $AUTO_YES; then
local ans
ans=$(_ask "Register mt5-quant with Claude Code now?" "yes")
[[ ! "$ans" =~ ^[Yy] ]] && register=false
_ok "Found platforms: ${platforms[*]}"
# Check for existing registrations
local has_existing=false
for platform in "${platforms[@]}"; do
if _is_registered_on_platform "$platform"; then
has_existing=true
local old_path
old_path=$(_get_platform_mcp_path "$platform")
_yellow "Existing registration detected on $platform"
[[ -n "$old_path" ]] && echo " Current path: $old_path"
fi
done
# Prompt for reinstall if any existing registrations found
if $has_existing && ! $AUTO_YES; then
echo ""
local reinstall_ans
reinstall_ans=$(_ask "Unregister existing installations and reinstall on all platforms?" "yes")
if [[ ! "$reinstall_ans" =~ ^[Yy] ]]; then
echo " Keeping existing registrations."
return
fi
fi
if $register; then
local out
out=$(claude mcp add mt5-quant -- python3 "${REPO_DIR}/server/main.py" 2>&1) || true
if echo "$out" | grep -qi "already\|exists"; then
_ok "Already registered (no change needed)"
elif echo "$out" | grep -qi "error\|failed"; then
_warn "Registration failed: $out"
echo " Run manually: claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
else
_ok "Registered: claude mcp add mt5-quant"
fi
else
echo " Skipped. Run manually:"
echo " claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
fi
# Unregister from all platforms first
for platform in "${platforms[@]}"; do
_is_registered_on_platform "$platform" && _unregister_from_platform "$platform"
done
# Register with all detected platforms
echo ""
_bold "Registering mt5-quant MCP..."
for platform in "${platforms[@]}"; do
# Use binary for Windsurf/Cursor, Python for Claude
case "$platform" in
windsurf|cursor)
_register_with_platform "$platform" true
;;
*)
_register_with_platform "$platform" false
;;
esac
done
echo ""
_green "MCP registration complete!"
echo ""
echo " Registered on: ${platforms[*]}"
echo ""
echo " Config locations:"
[[ " ${platforms[*]} " =~ " claude " ]] && echo " Claude Code: ~/.claude.json (managed via CLI)"
[[ " ${platforms[*]} " =~ " windsurf " ]] && echo " Windsurf: ~/.codeium/windsurf/mcp_config.json"
[[ " ${platforms[*]} " =~ " cursor " ]] && echo " Cursor: ~/.cursor/mcp.json"
[[ " ${platforms[*]} " =~ " vscode " ]] && echo " VS Code: .vscode/mcp.json"
echo ""
echo " Binary: ${REPO_DIR}/target/release/mt5-quant"
echo " Python: ${REPO_DIR}/server/main.py"
echo ""
}
main
+5 -4
View File
@@ -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 with 85 tools. Compile, backtest, analyze, optimize, and debug MQL5 EAs on macOS/Linux. Includes Wine/MT5 crash diagnostics.",
"repository": {
"url": "https://github.com/masdevid/mt5-quant",
"source": "github"
},
"version": "1.0.0",
"version": "1.30.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.30.0",
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.30.0/mcp-mt5-quant-macos-arm64.tar.gz",
"fileSha256": "PLACEHOLDER_SHA256",
"transport": {
"type": "stdio"
},
+610 -1
View File
@@ -1,4 +1,4 @@
use chrono::{DateTime, NaiveDateTime};
use chrono::{DateTime, Datelike, NaiveDateTime, Timelike};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -489,3 +489,612 @@ pub struct ConcurrentPeak {
pub peak_open: i32,
pub peak_time: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfitDistribution {
pub small_wins: i32,
pub medium_wins: i32,
pub large_wins: i32,
pub small_losses: i32,
pub medium_losses: i32,
pub large_losses: i32,
pub small_win_pnl: f64,
pub medium_win_pnl: f64,
pub large_win_pnl: f64,
pub small_loss_pnl: f64,
pub medium_loss_pnl: f64,
pub large_loss_pnl: f64,
pub buckets: Vec<ProfitBucket>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfitBucket {
pub range: String,
pub min: f64,
pub max: f64,
pub count: i32,
pub total_pnl: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimePerformance {
pub by_hour: Vec<HourPerformance>,
pub by_day: Vec<DayPerformance>,
pub best_hour: i32,
pub worst_hour: i32,
pub best_day: String,
pub worst_day: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HourPerformance {
pub hour: i32,
pub trades: i32,
pub wins: i32,
pub total_pnl: f64,
pub win_rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DayPerformance {
pub day: String,
pub day_num: i32,
pub trades: i32,
pub wins: i32,
pub total_pnl: f64,
pub win_rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HoldTimeAnalysis {
pub avg_hold_minutes: f64,
pub median_hold_minutes: f64,
pub buckets: Vec<HoldTimeBucket>,
pub correlation_with_profit: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HoldTimeBucket {
pub range: String,
pub min_minutes: f64,
pub max_minutes: f64,
pub count: i32,
pub avg_profit: f64,
pub total_pnl: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayerPerformance {
pub layer: i32,
pub trades: i32,
pub wins: i32,
pub total_pnl: f64,
pub win_rate: f64,
pub avg_volume: f64,
pub avg_profit: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeAnalysis {
pub correlation_with_profit: f64,
pub by_volume_bucket: Vec<VolumeBucket>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeBucket {
pub volume_range: String,
pub min_volume: f64,
pub max_volume: f64,
pub trades: i32,
pub avg_profit: f64,
pub total_pnl: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostAnalysis {
pub total_commission: f64,
pub total_swap: f64,
pub commission_pct_of_profit: f64,
pub swap_pct_of_profit: f64,
pub avg_commission_per_trade: f64,
pub avg_swap_per_trade: f64,
pub net_profit_before_costs: f64,
pub cost_impact_on_win_rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EfficiencyAnalysis {
pub profit_per_hour: f64,
pub profit_per_day: f64,
pub profit_per_trade_hour: f64,
pub avg_trade_duration_hours: f64,
pub annualized_return_pct: f64,
pub trades_per_day: f64,
}
impl DealAnalyzer {
pub fn profit_distribution(&self, deals: &[Deal]) -> ProfitDistribution {
let closed: Vec<&Deal> = deals
.iter()
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
.collect();
let mut small_wins = 0;
let mut medium_wins = 0;
let mut large_wins = 0;
let mut small_losses = 0;
let mut medium_losses = 0;
let mut large_losses = 0;
let mut small_win_pnl = 0.0;
let mut medium_win_pnl = 0.0;
let mut large_win_pnl = 0.0;
let mut small_loss_pnl = 0.0;
let mut medium_loss_pnl = 0.0;
let mut large_loss_pnl = 0.0;
for deal in &closed {
let profit = deal.profit;
if profit > 0.0 {
if profit < 50.0 {
small_wins += 1;
small_win_pnl += profit;
} else if profit < 200.0 {
medium_wins += 1;
medium_win_pnl += profit;
} else {
large_wins += 1;
large_win_pnl += profit;
}
} else {
let loss = profit.abs();
if loss < 50.0 {
small_losses += 1;
small_loss_pnl += profit;
} else if loss < 200.0 {
medium_losses += 1;
medium_loss_pnl += profit;
} else {
large_losses += 1;
large_loss_pnl += profit;
}
}
}
// Create detailed buckets
let bucket_ranges = [
(-999999.0, -500.0, "Loss $500+"),
(-500.0, -200.0, "Loss $200-500"),
(-200.0, -50.0, "Loss $50-200"),
(-50.0, 0.0, "Loss $0-50"),
(0.0, 50.0, "Win $0-50"),
(50.0, 200.0, "Win $50-200"),
(200.0, 500.0, "Win $200-500"),
(500.0, 999999.0, "Win $500+"),
];
let mut buckets: Vec<ProfitBucket> = bucket_ranges
.iter()
.map(|(min, max, range)| {
let count = closed
.iter()
.filter(|d| d.profit >= *min && d.profit < *max)
.count() as i32;
let total_pnl: f64 = closed
.iter()
.filter(|d| d.profit >= *min && d.profit < *max)
.map(|d| d.profit)
.sum();
ProfitBucket {
range: range.to_string(),
min: *min,
max: *max,
count,
total_pnl: (total_pnl * 100.0).round() / 100.0,
}
})
.collect();
// Remove empty buckets
buckets.retain(|b| b.count > 0);
ProfitDistribution {
small_wins,
medium_wins,
large_wins,
small_losses,
medium_losses,
large_losses,
small_win_pnl: (small_win_pnl * 100.0).round() / 100.0,
medium_win_pnl: (medium_win_pnl * 100.0).round() / 100.0,
large_win_pnl: (large_win_pnl * 100.0).round() / 100.0,
small_loss_pnl: (small_loss_pnl * 100.0).round() / 100.0,
medium_loss_pnl: (medium_loss_pnl * 100.0).round() / 100.0,
large_loss_pnl: (large_loss_pnl * 100.0).round() / 100.0,
buckets,
}
}
pub fn time_performance(&self, deals: &[Deal]) -> TimePerformance {
let closed: Vec<&Deal> = deals
.iter()
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
.collect();
let mut hourly: HashMap<i32, (i32, i32, f64)> = HashMap::new();
let mut daily: HashMap<String, (i32, i32, f64, i32)> = HashMap::new();
for deal in &closed {
if let Some(dt) = Self::parse_datetime(&deal.time) {
let hour = dt.hour() as i32;
let day_num = dt.weekday().num_days_from_monday() as i32;
let day_name = match dt.weekday() {
chrono::Weekday::Mon => "Mon",
chrono::Weekday::Tue => "Tue",
chrono::Weekday::Wed => "Wed",
chrono::Weekday::Thu => "Thu",
chrono::Weekday::Fri => "Fri",
chrono::Weekday::Sat => "Sat",
chrono::Weekday::Sun => "Sun",
}.to_string();
let entry = hourly.entry(hour).or_insert((0, 0, 0.0));
entry.0 += 1;
entry.2 += deal.profit;
if deal.profit > 0.0 {
entry.1 += 1;
}
let day_entry = daily.entry(day_name.clone()).or_insert((0, 0, 0.0, day_num));
day_entry.0 += 1;
day_entry.2 += deal.profit;
if deal.profit > 0.0 {
day_entry.1 += 1;
}
}
}
let mut by_hour: Vec<HourPerformance> = hourly
.into_iter()
.map(|(hour, (trades, wins, total_pnl))| HourPerformance {
hour,
trades,
wins,
total_pnl: (total_pnl * 100.0).round() / 100.0,
win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 },
})
.collect();
by_hour.sort_by_key(|h| h.hour);
let mut by_day: Vec<DayPerformance> = daily
.into_iter()
.map(|(day, (trades, wins, total_pnl, day_num))| DayPerformance {
day: day.clone(),
day_num,
trades,
wins,
total_pnl: (total_pnl * 100.0).round() / 100.0,
win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 },
})
.collect();
by_day.sort_by_key(|d| d.day_num);
let best_hour = by_hour.iter().max_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|h| h.hour).unwrap_or(-1);
let worst_hour = by_hour.iter().min_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|h| h.hour).unwrap_or(-1);
let best_day = by_day.iter().max_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|d| d.day.clone()).unwrap_or_default();
let worst_day = by_day.iter().min_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|d| d.day.clone()).unwrap_or_default();
TimePerformance {
by_hour,
by_day,
best_hour,
worst_hour,
best_day,
worst_day,
}
}
pub fn hold_time_analysis(&self, deals: &[Deal]) -> HoldTimeAnalysis {
let mut hold_times: Vec<(f64, f64)> = Vec::new(); // (hold_minutes, profit)
let mut open_pos: HashMap<String, DateTime<chrono::Utc>> = HashMap::new();
for deal in deals {
let entry = deal.entry.to_lowercase();
if let Some(dt) = Self::parse_datetime(&deal.time) {
if entry.contains("in") && !entry.contains("out") {
open_pos.insert(deal.order.clone(), dt);
} else if entry.contains("out") && deal.profit != 0.0 {
if let Some(in_time) = open_pos.remove(&deal.order) {
let hold_minutes = (dt - in_time).num_seconds() as f64 / 60.0;
if hold_minutes > 0.0 {
hold_times.push((hold_minutes, deal.profit));
}
}
}
}
}
if hold_times.is_empty() {
return HoldTimeAnalysis {
avg_hold_minutes: 0.0,
median_hold_minutes: 0.0,
buckets: vec![],
correlation_with_profit: 0.0,
};
}
let avg_hold = hold_times.iter().map(|(h, _)| *h).sum::<f64>() / hold_times.len() as f64;
let mut sorted_hold: Vec<f64> = hold_times.iter().map(|(h, _)| *h).collect();
sorted_hold.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median_hold = sorted_hold[sorted_hold.len() / 2];
// Calculate correlation
let n = hold_times.len() as f64;
let sum_x = hold_times.iter().map(|(h, _)| *h).sum::<f64>();
let sum_y = hold_times.iter().map(|(_, p)| *p).sum::<f64>();
let sum_xy = hold_times.iter().map(|(h, p)| h * p).sum::<f64>();
let sum_x2 = hold_times.iter().map(|(h, _)| h * h).sum::<f64>();
let sum_y2 = hold_times.iter().map(|(_, p)| p * p).sum::<f64>();
let correlation = if n > 1.0 {
let numerator = n * sum_xy - sum_x * sum_y;
let denominator = ((n * sum_x2 - sum_x * sum_x) * (n * sum_y2 - sum_y * sum_y)).sqrt();
if denominator > 0.0 { numerator / denominator } else { 0.0 }
} else {
0.0
};
// Create buckets
let bucket_defs = [
(0.0, 15.0, "< 15 min"),
(15.0, 60.0, "15-60 min"),
(60.0, 240.0, "1-4 hours"),
(240.0, 1440.0, "4-24 hours"),
(1440.0, 10080.0, "1-7 days"),
(10080.0, 999999.0, "> 7 days"),
];
let buckets: Vec<HoldTimeBucket> = bucket_defs
.iter()
.map(|(min, max, range)| {
let bucket_deals: Vec<(f64, f64)> = hold_times
.iter()
.filter(|(h, _)| *h >= *min && *h < *max)
.cloned()
.collect();
let count = bucket_deals.len() as i32;
let total_pnl: f64 = bucket_deals.iter().map(|(_, p)| *p).sum();
let avg_profit = if count > 0 { total_pnl / count as f64 } else { 0.0 };
HoldTimeBucket {
range: range.to_string(),
min_minutes: *min,
max_minutes: *max,
count,
avg_profit: (avg_profit * 100.0).round() / 100.0,
total_pnl: (total_pnl * 100.0).round() / 100.0,
}
})
.collect();
HoldTimeAnalysis {
avg_hold_minutes: (avg_hold * 10.0).round() / 10.0,
median_hold_minutes: (median_hold * 10.0).round() / 10.0,
buckets,
correlation_with_profit: (correlation * 1000.0).round() / 1000.0,
}
}
pub fn layer_performance(&self, deals: &[Deal]) -> Vec<LayerPerformance> {
let mut layer_stats: HashMap<i32, (i32, i32, f64, f64)> = HashMap::new();
for deal in deals {
let entry = deal.entry.to_lowercase();
if entry.contains("out") && deal.profit != 0.0 {
let layer = self.extract_layer(&deal.comment);
let stats = layer_stats.entry(layer).or_insert((0, 0, 0.0, 0.0));
stats.0 += 1;
stats.2 += deal.profit;
stats.3 += deal.volume;
if deal.profit > 0.0 {
stats.1 += 1;
}
}
}
let mut result: Vec<LayerPerformance> = layer_stats
.into_iter()
.map(|(layer, (trades, wins, total_pnl, total_volume))| LayerPerformance {
layer,
trades,
wins,
total_pnl: (total_pnl * 100.0).round() / 100.0,
win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 },
avg_volume: if trades > 0 { (total_volume / trades as f64 * 10000.0).round() / 10000.0 } else { 0.0 },
avg_profit: if trades > 0 { (total_pnl / trades as f64 * 100.0).round() / 100.0 } else { 0.0 },
})
.collect();
result.sort_by_key(|l| l.layer);
result
}
pub fn volume_analysis(&self, deals: &[Deal]) -> VolumeAnalysis {
let closed: Vec<&Deal> = deals
.iter()
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
.collect();
if closed.is_empty() {
return VolumeAnalysis {
correlation_with_profit: 0.0,
by_volume_bucket: vec![],
};
}
// Calculate correlation
let n = closed.len() as f64;
let sum_x: f64 = closed.iter().map(|d| d.volume).sum();
let sum_y: f64 = closed.iter().map(|d| d.profit).sum();
let sum_xy: f64 = closed.iter().map(|d| d.volume * d.profit).sum();
let sum_x2: f64 = closed.iter().map(|d| d.volume * d.volume).sum();
let sum_y2: f64 = closed.iter().map(|d| d.profit * d.profit).sum();
let correlation = if n > 1.0 {
let numerator = n * sum_xy - sum_x * sum_y;
let denominator = ((n * sum_x2 - sum_x * sum_x) * (n * sum_y2 - sum_y * sum_y)).sqrt();
if denominator > 0.0 { numerator / denominator } else { 0.0 }
} else {
0.0
};
// Create volume buckets
let bucket_defs = [
(0.0, 0.1, "0.0-0.1 lots"),
(0.1, 0.5, "0.1-0.5 lots"),
(0.5, 1.0, "0.5-1.0 lots"),
(1.0, 2.0, "1.0-2.0 lots"),
(2.0, 5.0, "2.0-5.0 lots"),
(5.0, 999.0, "5.0+ lots"),
];
let by_volume_bucket: Vec<VolumeBucket> = bucket_defs
.iter()
.map(|(min, max, range)| {
let bucket_deals: Vec<&Deal> = closed
.iter()
.filter(|d| d.volume >= *min && d.volume < *max)
.cloned()
.collect();
let trades = bucket_deals.len() as i32;
let total_pnl: f64 = bucket_deals.iter().map(|d| d.profit).sum();
let avg_profit = if trades > 0 { total_pnl / trades as f64 } else { 0.0 };
VolumeBucket {
volume_range: range.to_string(),
min_volume: *min,
max_volume: *max,
trades,
avg_profit: (avg_profit * 100.0).round() / 100.0,
total_pnl: (total_pnl * 100.0).round() / 100.0,
}
})
.collect();
VolumeAnalysis {
correlation_with_profit: (correlation * 1000.0).round() / 1000.0,
by_volume_bucket,
}
}
pub fn cost_analysis(&self, deals: &[Deal]) -> CostAnalysis {
let total_commission: f64 = deals.iter().map(|d| d.commission.abs()).sum();
let total_swap: f64 = deals.iter().map(|d| d.swap.abs()).sum();
let gross_profit: f64 = deals.iter().map(|d| d.profit).filter(|p| *p > 0.0).sum();
let trade_count = deals.iter().filter(|d| d.entry.to_lowercase().contains("out")).count() as f64;
let commission_pct = if gross_profit > 0.0 { (total_commission / gross_profit * 10000.0).round() / 100.0 } else { 0.0 };
let swap_pct = if gross_profit > 0.0 { (total_swap / gross_profit * 10000.0).round() / 100.0 } else { 0.0 };
// Calculate what win rate would be without costs
let wins_before_costs = deals
.iter()
.filter(|d| {
let profit_before_costs = d.profit + d.commission.abs() + d.swap.abs();
d.entry.to_lowercase().contains("out") && profit_before_costs > 0.0
})
.count() as f64;
let total_closed = deals.iter().filter(|d| d.entry.to_lowercase().contains("out")).count() as f64;
let win_rate_before_costs = if total_closed > 0.0 { wins_before_costs / total_closed * 100.0 } else { 0.0 };
let current_wins = deals.iter().filter(|d| d.entry.to_lowercase().contains("out") && d.profit > 0.0).count() as f64;
let current_win_rate = if total_closed > 0.0 { current_wins / total_closed * 100.0 } else { 0.0 };
CostAnalysis {
total_commission: (total_commission * 100.0).round() / 100.0,
total_swap: (total_swap * 100.0).round() / 100.0,
commission_pct_of_profit: commission_pct,
swap_pct_of_profit: swap_pct,
avg_commission_per_trade: if trade_count > 0.0 { (total_commission / trade_count * 100.0).round() / 100.0 } else { 0.0 },
avg_swap_per_trade: if trade_count > 0.0 { (total_swap / trade_count * 100.0).round() / 100.0 } else { 0.0 },
net_profit_before_costs: (gross_profit * 100.0).round() / 100.0,
cost_impact_on_win_rate: (win_rate_before_costs - current_win_rate * 100.0).round() / 100.0,
}
}
pub fn efficiency_analysis(&self, deals: &[Deal], _metrics: &Metrics) -> EfficiencyAnalysis {
let closed: Vec<&Deal> = deals
.iter()
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
.collect();
if closed.is_empty() {
return EfficiencyAnalysis {
profit_per_hour: 0.0,
profit_per_day: 0.0,
profit_per_trade_hour: 0.0,
avg_trade_duration_hours: 0.0,
annualized_return_pct: 0.0,
trades_per_day: 0.0,
};
}
let total_profit: f64 = closed.iter().map(|d| d.profit).sum();
let total_trades = closed.len() as f64;
// Calculate total hold time
let mut total_hold_minutes = 0.0;
let mut open_pos: HashMap<String, DateTime<chrono::Utc>> = HashMap::new();
for deal in deals {
let entry = deal.entry.to_lowercase();
if let Some(dt) = Self::parse_datetime(&deal.time) {
if entry.contains("in") && !entry.contains("out") {
open_pos.insert(deal.order.clone(), dt);
} else if entry.contains("out") && deal.profit != 0.0 {
if let Some(in_time) = open_pos.remove(&deal.order) {
let hold_minutes = (dt - in_time).num_seconds() as f64 / 60.0;
if hold_minutes > 0.0 {
total_hold_minutes += hold_minutes;
}
}
}
}
}
let total_hold_hours = total_hold_minutes / 60.0;
let avg_trade_duration = if total_trades > 0.0 { total_hold_minutes / total_trades / 60.0 } else { 0.0 };
// Get date range
let dates: Vec<DateTime<chrono::Utc>> = deals
.iter()
.filter_map(|d| Self::parse_datetime(&d.time))
.collect();
let total_days = if dates.len() >= 2 {
let min_date = dates.iter().min().unwrap();
let max_date = dates.iter().max().unwrap();
(*max_date - *min_date).num_days().max(1) as f64
} else {
1.0
};
// Use a default deposit of 10000 for annualized calculation
// In real scenarios, this should come from the report
let deposit = 10000.0;
let annualized = if total_days > 0.0 && deposit > 0.0 {
let daily_return = total_profit / deposit / total_days;
((1.0 + daily_return).powf(365.0) - 1.0) * 100.0
} else {
0.0
};
EfficiencyAnalysis {
profit_per_hour: if total_hold_hours > 0.0 { (total_profit / total_hold_hours * 100.0).round() / 100.0 } else { 0.0 },
profit_per_day: (total_profit / total_days * 100.0).round() / 100.0,
profit_per_trade_hour: if total_hold_hours > 0.0 { (total_profit / total_hold_hours / total_trades * 100.0).round() / 100.0 } else { 0.0 },
avg_trade_duration_hours: (avg_trade_duration * 10.0).round() / 10.0,
annualized_return_pct: (annualized * 10.0).round() / 10.0,
trades_per_day: (total_trades / total_days * 10.0).round() / 10.0,
}
}
}
-141
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+539
View File
@@ -374,4 +374,543 @@ impl ReportDb {
Ok(entry)
}
/// Get a specific report by ID
pub fn get_by_id(&self, id: &str) -> Result<Option<ReportEntry>> {
let conn = self.connect()?;
let mut stmt = conn.prepare(
"SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE id = ?"
)?;
let entry = stmt
.query_map([id], |row| {
let tags_json: String = row.get(23)?;
let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.next();
Ok(entry)
}
/// Search reports by tags (at least one tag must match)
pub fn search_by_tags(&self, tags: &[String], limit: usize) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE 1=1"
.to_string();
// Build OR conditions for tags - use JSON1 extension for tag matching
if !tags.is_empty() {
let tag_conditions: Vec<String> = tags.iter()
.map(|tag| format!("tags LIKE '%{}%'", tag.replace("'", "''")))
.collect();
sql.push_str(&format!(" AND ({})", tag_conditions.join(" OR ")));
}
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
let mut stmt = conn.prepare(&sql)?;
let entries: Vec<ReportEntry> = stmt
.query_map([], |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Search reports by notes text (case-insensitive LIKE)
pub fn search_by_notes(&self, query: &str, limit: usize) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let pattern = format!("%{}%", query.replace("'", "''"));
let mut stmt = conn.prepare(
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE notes LIKE '{}' ORDER BY created_at DESC LIMIT {}",
pattern, limit)
)?;
let entries: Vec<ReportEntry> = stmt
.query_map([], |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Find reports by set file (original or snapshot)
pub fn search_by_set_file(&self, set_file: &str, limit: usize) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let pattern = format!("%{}%", set_file.replace("'", "''"));
let mut stmt = conn.prepare(
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE (set_file_original LIKE '{}' OR set_snapshot_path LIKE '{}') \
ORDER BY created_at DESC LIMIT {}",
pattern, pattern, limit)
)?;
let entries: Vec<ReportEntry> = stmt
.query_map([], |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Search by backtest date range (from_date and to_date fields)
pub fn search_by_date_range(
&self,
from_start: Option<&str>,
from_end: Option<&str>,
to_start: Option<&str>,
to_end: Option<&str>,
limit: usize,
) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE 1=1"
.to_string();
let mut params: Vec<String> = Vec::new();
if let Some(start) = from_start {
sql.push_str(" AND from_date >= ?");
params.push(start.to_string());
}
if let Some(end) = from_end {
sql.push_str(" AND from_date <= ?");
params.push(end.to_string());
}
if let Some(start) = to_start {
sql.push_str(" AND to_date >= ?");
params.push(start.to_string());
}
if let Some(end) = to_end {
sql.push_str(" AND to_date <= ?");
params.push(end.to_string());
}
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
let mut stmt = conn.prepare(&sql)?;
let entries: Vec<ReportEntry> = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Get comparable reports (same expert/symbol/timeframe for comparison)
pub fn get_comparable(
&self,
expert: &str,
symbol: &str,
timeframe: &str,
exclude_id: Option<&str>,
limit: usize,
) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE expert = ? AND symbol = ? AND timeframe = ?"
.to_string();
let mut params: Vec<String> = vec![
expert.to_string(),
symbol.to_string(),
timeframe.to_string(),
];
if let Some(id) = exclude_id {
sql.push_str(" AND id != ?");
params.push(id.to_string());
}
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
let mut stmt = conn.prepare(&sql)?;
let entries: Vec<ReportEntry> = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Get reports sorted by a specific metric (for best/worst queries)
pub fn get_sorted_by(
&self,
sort_column: &str,
ascending: bool,
limit: usize,
filters: &ReportFilters,
) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
// Validate sort column to prevent SQL injection
let valid_columns = ["net_profit", "profit_factor", "max_dd_pct", "win_rate_pct",
"sharpe_ratio", "recovery_factor", "total_trades", "created_at"];
if !valid_columns.contains(&sort_column) {
return Err(anyhow::anyhow!("Invalid sort column: {}", sort_column));
}
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE 1=1"
.to_string();
let mut filter_params: Vec<String> = Vec::new();
if let Some(ea) = &filters.expert {
sql.push_str(" AND expert LIKE ?");
filter_params.push(format!("%{}%", ea));
}
if let Some(sym) = &filters.symbol {
sql.push_str(" AND symbol = ?");
filter_params.push(sym.clone());
}
if let Some(tf) = &filters.timeframe {
sql.push_str(" AND timeframe = ?");
filter_params.push(tf.clone());
}
if let Some(verdict) = &filters.verdict {
sql.push_str(" AND verdict = ?");
filter_params.push(verdict.clone());
}
// For non-created_at columns, only include rows where that column is not NULL
if sort_column != "created_at" {
sql.push_str(&format!(" AND {} IS NOT NULL", sort_column));
}
let order = if ascending { "ASC" } else { "DESC" };
sql.push_str(&format!(" ORDER BY {} {} LIMIT {}", sort_column, order, limit));
let mut stmt = conn.prepare(&sql)?;
let entries: Vec<ReportEntry> = stmt
.query_map(rusqlite::params_from_iter(filter_params.iter()), |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Get aggregate statistics for reports
pub fn get_stats(&self, filters: &ReportFilters) -> Result<ReportStats> {
let conn = self.connect()?;
let mut sql = "SELECT \
COUNT(*), \
AVG(net_profit), \
AVG(profit_factor), \
AVG(max_dd_pct), \
AVG(win_rate_pct), \
AVG(sharpe_ratio), \
SUM(CASE WHEN net_profit > 0 THEN 1 ELSE 0 END), \
SUM(CASE WHEN verdict = 'pass' THEN 1 ELSE 0 END), \
SUM(CASE WHEN verdict = 'fail' THEN 1 ELSE 0 END), \
SUM(CASE WHEN verdict = 'marginal' THEN 1 ELSE 0 END) \
FROM reports WHERE 1=1"
.to_string();
let mut filter_params: Vec<String> = Vec::new();
if let Some(ea) = &filters.expert {
sql.push_str(" AND expert LIKE ?");
filter_params.push(format!("%{}%", ea));
}
if let Some(sym) = &filters.symbol {
sql.push_str(" AND symbol = ?");
filter_params.push(sym.clone());
}
if let Some(tf) = &filters.timeframe {
sql.push_str(" AND timeframe = ?");
filter_params.push(tf.clone());
}
if let Some(verdict) = &filters.verdict {
sql.push_str(" AND verdict = ?");
filter_params.push(verdict.clone());
}
let stats: ReportStats = conn.query_row(
&sql,
rusqlite::params_from_iter(filter_params.iter()),
|row| {
let total: i64 = row.get(0)?;
let profitable: Option<i64> = row.get(6)?;
let pass_count: Option<i64> = row.get(7)?;
let fail_count: Option<i64> = row.get(8)?;
let marginal_count: Option<i64> = row.get(9)?;
Ok(ReportStats {
total_count: total as usize,
avg_net_profit: row.get(1)?,
avg_profit_factor: row.get(2)?,
avg_max_dd_pct: row.get(3)?,
avg_win_rate_pct: row.get(4)?,
avg_sharpe_ratio: row.get(5)?,
profitable_count: profitable.unwrap_or(0) as usize,
pass_verdict_count: pass_count.unwrap_or(0) as usize,
fail_verdict_count: fail_count.unwrap_or(0) as usize,
marginal_verdict_count: marginal_count.unwrap_or(0) as usize,
})
},
)?;
Ok(stats)
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ReportStats {
pub total_count: usize,
pub avg_net_profit: Option<f64>,
pub avg_profit_factor: Option<f64>,
pub avg_max_dd_pct: Option<f64>,
pub avg_win_rate_pct: Option<f64>,
pub avg_sharpe_ratio: Option<f64>,
pub profitable_count: usize,
pub pass_verdict_count: usize,
pub fail_verdict_count: usize,
pub marginal_verdict_count: usize,
}
+152
View File
@@ -126,3 +126,155 @@ pub fn tool_analyze_concurrent_peak() -> Value {
}
})
}
pub fn tool_list_deals() -> Value {
json!({
"name": "list_deals",
"description": "List individual deals from a backtest report with optional filters",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" },
"deal_type": { "type": "string", "enum": ["buy", "sell"], "description": "Filter by deal type" },
"min_profit": { "type": "number", "description": "Minimum profit (use negative for losses)" },
"max_profit": { "type": "number", "description": "Maximum profit" },
"start_date": { "type": "string", "description": "Start date filter (YYYY.MM.DD)" },
"end_date": { "type": "string", "description": "End date filter (YYYY.MM.DD)" },
"min_volume": { "type": "number", "description": "Minimum volume/lots" },
"max_volume": { "type": "number", "description": "Maximum volume/lots" },
"limit": { "type": "integer", "default": 100, "description": "Max deals to return" }
}
}
})
}
pub fn tool_search_deals_by_comment() -> Value {
json!({
"name": "search_deals_by_comment",
"description": "Search deals by comment text (case-insensitive partial match)",
"inputSchema": {
"type": "object",
"required": ["report_dir", "query"],
"properties": {
"report_dir": { "type": "string" },
"query": { "type": "string", "description": "Search text in comments" },
"limit": { "type": "integer", "default": 50 }
}
}
})
}
pub fn tool_search_deals_by_magic() -> Value {
json!({
"name": "search_deals_by_magic",
"description": "Filter deals by magic number (EA identifier)",
"inputSchema": {
"type": "object",
"required": ["report_dir", "magic"],
"properties": {
"report_dir": { "type": "string" },
"magic": { "type": "string", "description": "Magic number to filter by" },
"limit": { "type": "integer", "default": 100 }
}
}
})
}
pub fn tool_analyze_profit_distribution() -> Value {
json!({
"name": "analyze_profit_distribution",
"description": "Analyze profit distribution - small/medium/large wins and losses with detailed buckets",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
pub fn tool_analyze_time_performance() -> Value {
json!({
"name": "analyze_time_performance",
"description": "Analyze performance by hour of day and day of week",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
pub fn tool_analyze_hold_time_distribution() -> Value {
json!({
"name": "analyze_hold_time_distribution",
"description": "Analyze hold time distribution and correlation with profit",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
pub fn tool_analyze_layer_performance() -> Value {
json!({
"name": "analyze_layer_performance",
"description": "Analyze performance by grid layer (extracted from deal comments)",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
pub fn tool_analyze_volume_vs_profit() -> Value {
json!({
"name": "analyze_volume_vs_profit",
"description": "Analyze correlation between volume and profit, plus performance by volume bucket",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
pub fn tool_analyze_costs() -> Value {
json!({
"name": "analyze_costs",
"description": "Analyze commission and swap costs impact on profitability",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
pub fn tool_analyze_efficiency() -> Value {
json!({
"name": "analyze_efficiency",
"description": "Calculate efficiency metrics: profit per hour/day, annualized return, trade frequency",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
+98
View File
@@ -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"]
}
})
}
+47 -2
View File
@@ -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![
@@ -21,7 +22,7 @@ pub fn get_tools_list() -> Value {
optimization::tool_get_optimization_status(),
optimization::tool_get_optimization_results(),
optimization::tool_list_jobs(),
// Analytics (9 tools)
// Analytics (19 tools)
analytics::tool_analyze_report(),
analytics::tool_analyze_monthly_pnl(),
analytics::tool_analyze_drawdown_events(),
@@ -31,6 +32,17 @@ pub fn get_tools_list() -> Value {
analytics::tool_analyze_direction_bias(),
analytics::tool_analyze_streaks(),
analytics::tool_analyze_concurrent_peak(),
// Deal query tools (10)
analytics::tool_list_deals(),
analytics::tool_search_deals_by_comment(),
analytics::tool_search_deals_by_magic(),
analytics::tool_analyze_profit_distribution(),
analytics::tool_analyze_time_performance(),
analytics::tool_analyze_hold_time_distribution(),
analytics::tool_analyze_layer_performance(),
analytics::tool_analyze_volume_vs_profit(),
analytics::tool_analyze_costs(),
analytics::tool_analyze_efficiency(),
// Baseline
baseline::tool_compare_baseline(),
// Experts
@@ -38,10 +50,35 @@ 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(),
// Debugging/Diagnostics (10 tools)
utility::tool_diagnose_wine(),
utility::tool_get_mt5_logs(),
utility::tool_search_mt5_errors(),
utility::tool_check_mt5_process(),
utility::tool_kill_mt5_process(),
utility::tool_check_system_resources(),
utility::tool_validate_mt5_config(),
utility::tool_get_wine_prefix_info(),
utility::tool_get_backtest_crash_info(),
// Set Files
setfiles::tool_read_set_file(),
setfiles::tool_write_set_file(),
@@ -51,7 +88,7 @@ pub fn get_tools_list() -> Value {
setfiles::tool_describe_sweep(),
setfiles::tool_list_set_files(),
setfiles::tool_set_from_optimization(),
// Reports (11 tools)
// Reports (19 tools)
reports::tool_list_reports(),
reports::tool_search_reports(),
reports::tool_get_latest_report(),
@@ -62,6 +99,14 @@ pub fn get_tools_list() -> Value {
reports::tool_get_history(),
reports::tool_promote_to_baseline(),
reports::tool_annotate_history(),
reports::tool_get_report_by_id(),
reports::tool_get_reports_summary(),
reports::tool_get_best_reports(),
reports::tool_search_reports_by_tags(),
reports::tool_search_reports_by_date_range(),
reports::tool_search_reports_by_notes(),
reports::tool_get_reports_by_set_file(),
reports::tool_get_comparable_reports(),
];
serde_json::json!(tools)
+130
View File
@@ -148,3 +148,133 @@ pub fn tool_prune_reports() -> Value {
}
})
}
pub fn tool_get_report_by_id() -> Value {
json!({
"name": "get_report_by_id",
"description": "Get a specific report by its ID with full details and optional equity chart",
"inputSchema": {
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "string", "description": "Report ID (from list_reports or search_reports)" },
"include_chart": { "type": "boolean", "description": "Include equity chart as base64 PNG (default: true)", "default": true }
}
}
})
}
pub fn tool_get_reports_summary() -> Value {
json!({
"name": "get_reports_summary",
"description": "Get aggregate statistics across reports - counts, averages by EA/symbol/timeframe/verdict",
"inputSchema": {
"type": "object",
"properties": {
"expert": { "type": "string", "description": "Filter by EA name substring" },
"symbol": { "type": "string", "description": "Filter by exact symbol" },
"timeframe": { "type": "string", "description": "Filter by exact timeframe" },
"verdict": { "type": "string", "description": "Filter by verdict (pass/fail/marginal)" }
}
}
})
}
pub fn tool_get_best_reports() -> Value {
json!({
"name": "get_best_reports",
"description": "Get top N reports sorted by performance metric (profit factor, win rate, drawdown, etc.)",
"inputSchema": {
"type": "object",
"properties": {
"sort_by": { "type": "string", "enum": ["net_profit", "profit_factor", "max_dd_pct", "win_rate_pct", "sharpe_ratio", "recovery_factor", "total_trades"], "default": "profit_factor", "description": "Metric to sort by" },
"order": { "type": "string", "enum": ["asc", "desc"], "default": "desc", "description": "Sort order (use 'asc' for drawdown, 'desc' for profit)" },
"limit": { "type": "integer", "default": 10, "description": "Number of reports to return" },
"expert": { "type": "string", "description": "Filter by EA name substring" },
"symbol": { "type": "string", "description": "Filter by exact symbol" },
"timeframe": { "type": "string", "description": "Filter by exact timeframe" },
"verdict": { "type": "string", "description": "Filter by verdict" }
}
}
})
}
pub fn tool_search_reports_by_tags() -> Value {
json!({
"name": "search_reports_by_tags",
"description": "Search reports by tags - at least one tag must match (OR logic)",
"inputSchema": {
"type": "object",
"required": ["tags"],
"properties": {
"tags": { "type": "array", "items": { "type": "string" }, "description": "Tags to search for (e.g., ['production', 'verified'])" },
"limit": { "type": "integer", "default": 50 }
}
}
})
}
pub fn tool_search_reports_by_date_range() -> Value {
json!({
"name": "search_reports_by_date_range",
"description": "Search reports by backtest date range (from_date and to_date fields)",
"inputSchema": {
"type": "object",
"properties": {
"from_start": { "type": "string", "description": "From date >= this (YYYY.MM.DD)" },
"from_end": { "type": "string", "description": "From date <= this (YYYY.MM.DD)" },
"to_start": { "type": "string", "description": "To date >= this (YYYY.MM.DD)" },
"to_end": { "type": "string", "description": "To date <= this (YYYY.MM.DD)" },
"limit": { "type": "integer", "default": 50 }
}
}
})
}
pub fn tool_search_reports_by_notes() -> Value {
json!({
"name": "search_reports_by_notes",
"description": "Full-text search in report notes field (case-insensitive LIKE search)",
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": { "type": "string", "description": "Search text (partial match)" },
"limit": { "type": "integer", "default": 50 }
}
}
})
}
pub fn tool_get_reports_by_set_file() -> Value {
json!({
"name": "get_reports_by_set_file",
"description": "Find all reports that used a specific .set parameter file",
"inputSchema": {
"type": "object",
"required": ["set_file"],
"properties": {
"set_file": { "type": "string", "description": "Set filename or partial path to match" },
"limit": { "type": "integer", "default": 50 }
}
}
})
}
pub fn tool_get_comparable_reports() -> Value {
json!({
"name": "get_comparable_reports",
"description": "Find reports comparable to a given report (same EA, symbol, timeframe) - useful for before/after analysis",
"inputSchema": {
"type": "object",
"properties": {
"report_id": { "type": "string", "description": "Reference report ID (if provided, uses its expert/symbol/timeframe)" },
"expert": { "type": "string", "description": "EA name (required if report_id not provided)" },
"symbol": { "type": "string", "description": "Symbol (required if report_id not provided)" },
"timeframe": { "type": "string", "description": "Timeframe (required if report_id not provided)" },
"exclude_id": { "type": "string", "description": "Exclude this report ID from results" },
"limit": { "type": "integer", "default": 20 }
}
}
})
}
+10
View 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"
}
})
}
+313
View File
@@ -0,0 +1,313 @@
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"]
}
})
}
pub fn tool_diagnose_wine() -> Value {
json!({
"name": "diagnose_wine",
"description": "Check Wine installation, version, and prefix health. Reports errors, warnings, and recent Wine errors.",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_get_mt5_logs() -> Value {
json!({
"name": "get_mt5_logs",
"description": "Get MT5 terminal, tester, or MetaEditor logs with optional search filtering",
"inputSchema": {
"type": "object",
"properties": {
"log_type": {
"type": "string",
"enum": ["terminal", "tester", "metaeditor"],
"default": "terminal",
"description": "Type of log to retrieve"
},
"lines": {
"type": "integer",
"default": 100,
"description": "Number of lines to return (from end of log)"
},
"search": {
"type": "string",
"description": "Optional search term to filter log lines"
}
}
}
})
}
pub fn tool_search_mt5_errors() -> Value {
json!({
"name": "search_mt5_errors",
"description": "Search MT5 logs for error patterns (error, failed, crash, exception, etc.) in recent hours",
"inputSchema": {
"type": "object",
"properties": {
"hours_back": {
"type": "integer",
"default": 24,
"description": "Hours to search back in logs"
},
"max_errors": {
"type": "integer",
"default": 50,
"description": "Maximum number of errors to return"
}
}
}
})
}
pub fn tool_check_mt5_process() -> Value {
json!({
"name": "check_mt5_process",
"description": "Check if MT5 processes are running, get process info (PID, CPU, memory usage)",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_kill_mt5_process() -> Value {
json!({
"name": "kill_mt5_process",
"description": "Kill stuck MT5 processes. Use force=true for stuck wineserver.",
"inputSchema": {
"type": "object",
"properties": {
"pid": {
"type": "string",
"description": "Optional specific PID to kill"
},
"force": {
"type": "boolean",
"default": false,
"description": "Use SIGKILL (-9) instead of SIGTERM (-15), also kills wineserver"
}
}
}
})
}
pub fn tool_check_system_resources() -> Value {
json!({
"name": "check_system_resources",
"description": "Check disk space, memory, and CPU. Warns if resources are low for MT5 operations.",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_validate_mt5_config() -> Value {
json!({
"name": "validate_mt5_config",
"description": "Validate MT5 configuration files (terminal.ini, tester settings). Reports errors and warnings.",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_get_wine_prefix_info() -> Value {
json!({
"name": "get_wine_prefix_info",
"description": "Get detailed Wine prefix information: Windows version, installed programs, registry files, drive_c size",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_get_backtest_crash_info() -> Value {
json!({
"name": "get_backtest_crash_info",
"description": "Investigate backtest crashes/failures. Checks for incomplete markers, missing deals.csv, error logs. Can scan recent reports.",
"inputSchema": {
"type": "object",
"properties": {
"report_dir": {
"type": "string",
"description": "Optional specific report directory to check"
},
"check_recent": {
"type": "boolean",
"default": true,
"description": "Also check recent reports for failures"
},
"hours_back": {
"type": "integer",
"default": 6,
"description": "Hours back to check for recent failures"
}
}
}
})
}
+331
View File
@@ -305,5 +305,336 @@ pub async fn handle_analyze_concurrent_peak(_config: &Config, args: &Value) -> R
}))
}
// === Deal Query Handlers ===
pub async fn handle_list_deals(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
// Apply filters
let deal_type = args.get("deal_type").and_then(|v| v.as_str());
let min_profit = args.get("min_profit").and_then(|v| v.as_f64());
let max_profit = args.get("max_profit").and_then(|v| v.as_f64());
let start_date = args.get("start_date").and_then(|v| v.as_str());
let end_date = args.get("end_date").and_then(|v| v.as_str());
let min_volume = args.get("min_volume").and_then(|v| v.as_f64());
let max_volume = args.get("max_volume").and_then(|v| v.as_f64());
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
let mut filtered: Vec<&Deal> = deals.iter().filter(|d| {
// Only include closed trades with non-zero profit
if !d.entry.to_lowercase().contains("out") || d.profit == 0.0 {
return false;
}
if let Some(dt) = deal_type {
if !d.deal_type.to_lowercase().contains(dt) {
return false;
}
}
if let Some(min) = min_profit {
if d.profit < min {
return false;
}
}
if let Some(max) = max_profit {
if d.profit > max {
return false;
}
}
if let Some(start) = start_date {
if !d.time.starts_with(start) && d.time < start.to_string() {
return false;
}
}
if let Some(end) = end_date {
if d.time > end.to_string() {
return false;
}
}
if let Some(min) = min_volume {
if d.volume < min {
return false;
}
}
if let Some(max) = max_volume {
if d.volume > max {
return false;
}
}
true
}).collect();
// Sort by time descending
filtered.sort_by(|a, b| b.time.cmp(&a.time));
filtered.truncate(limit);
let deal_list: Vec<Value> = filtered
.iter()
.map(|d| json!({
"time": d.time,
"deal": d.deal,
"symbol": d.symbol,
"deal_type": d.deal_type,
"volume": d.volume,
"price": d.price,
"profit": d.profit,
"commission": d.commission,
"swap": d.swap,
"comment": d.comment,
"magic": d.magic,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"total_deals": deals.len(),
"filtered_count": deal_list.len(),
"deals": deal_list,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_search_deals_by_comment(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let query = args.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("query is required"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let (deals, _) = load_report_data(report_dir)?;
let query_lower = query.to_lowercase();
let mut filtered: Vec<&Deal> = deals
.iter()
.filter(|d| {
d.entry.to_lowercase().contains("out")
&& d.profit != 0.0
&& d.comment.to_lowercase().contains(&query_lower)
})
.collect();
filtered.sort_by(|a, b| b.time.cmp(&a.time));
filtered.truncate(limit);
let deal_list: Vec<Value> = filtered
.iter()
.map(|d| json!({
"time": d.time,
"deal": d.deal,
"symbol": d.symbol,
"deal_type": d.deal_type,
"volume": d.volume,
"profit": d.profit,
"comment": d.comment,
"magic": d.magic,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"query": query,
"matched": deal_list.len(),
"deals": deal_list,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_search_deals_by_magic(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let magic = args.get("magic")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("magic is required"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
let (deals, _) = load_report_data(report_dir)?;
let mut filtered: Vec<&Deal> = deals
.iter()
.filter(|d| {
d.entry.to_lowercase().contains("out")
&& d.profit != 0.0
&& d.magic.as_ref().map(|m| m.contains(magic)).unwrap_or(false)
})
.collect();
filtered.sort_by(|a, b| b.time.cmp(&a.time));
filtered.truncate(limit);
let deal_list: Vec<Value> = filtered
.iter()
.map(|d| json!({
"time": d.time,
"deal": d.deal,
"symbol": d.symbol,
"deal_type": d.deal_type,
"volume": d.volume,
"profit": d.profit,
"comment": d.comment,
"magic": d.magic,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"magic": magic,
"matched": deal_list.len(),
"deals": deal_list,
}).to_string() }],
"isError": false
}))
}
// === New Analytics Handlers ===
pub async fn handle_analyze_profit_distribution(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.profit_distribution(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"profit_distribution": result,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_analyze_time_performance(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.time_performance(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"time_performance": result,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_analyze_hold_time_distribution(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.hold_time_analysis(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"hold_time_analysis": result,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_analyze_layer_performance(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.layer_performance(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"layer_performance": result,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_analyze_volume_vs_profit(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.volume_analysis(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"volume_analysis": result,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_analyze_costs(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.cost_analysis(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"cost_analysis": result,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_analyze_efficiency(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, metrics) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.efficiency_analysis(&deals, &metrics);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"efficiency_analysis": result,
}).to_string() }],
"isError": false
}))
}
// Import Config for analysis module
use crate::models::Config;
+119 -16
View File
@@ -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) = {
+309 -8
View File
@@ -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
}))
}
}
}
+46
View File
@@ -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,
@@ -58,6 +65,17 @@ impl ToolHandler {
"analyze_streaks" => analysis::handle_analyze_streaks(&self.config, args).await,
"analyze_concurrent_peak" => analysis::handle_analyze_concurrent_peak(&self.config, args).await,
"compare_baseline" => analysis::handle_compare_baseline(&self.config, args).await,
// Deal query handlers
"list_deals" => analysis::handle_list_deals(&self.config, args).await,
"search_deals_by_comment" => analysis::handle_search_deals_by_comment(&self.config, args).await,
"search_deals_by_magic" => analysis::handle_search_deals_by_magic(&self.config, args).await,
"analyze_profit_distribution" => analysis::handle_analyze_profit_distribution(&self.config, args).await,
"analyze_time_performance" => analysis::handle_analyze_time_performance(&self.config, args).await,
"analyze_hold_time_distribution" => analysis::handle_analyze_hold_time_distribution(&self.config, args).await,
"analyze_layer_performance" => analysis::handle_analyze_layer_performance(&self.config, args).await,
"analyze_volume_vs_profit" => analysis::handle_analyze_volume_vs_profit(&self.config, args).await,
"analyze_costs" => analysis::handle_analyze_costs(&self.config, args).await,
"analyze_efficiency" => analysis::handle_analyze_efficiency(&self.config, args).await,
// Set file handlers
"read_set_file" => setfiles::handle_read_set_file(args).await,
@@ -80,7 +98,35 @@ impl ToolHandler {
"promote_to_baseline" => reports::handle_promote_to_baseline(&self.config, args).await,
"get_history" => reports::handle_get_history(args).await,
"annotate_history" => reports::handle_annotate_history(args).await,
"get_report_by_id" => reports::handle_get_report_by_id(&self.config, args).await,
"get_reports_summary" => reports::handle_get_reports_summary(args).await,
"get_best_reports" => reports::handle_get_best_reports(args).await,
"search_reports_by_tags" => reports::handle_search_reports_by_tags(args).await,
"search_reports_by_date_range" => reports::handle_search_reports_by_date_range(args).await,
"search_reports_by_notes" => reports::handle_search_reports_by_notes(args).await,
"get_reports_by_set_file" => reports::handle_get_reports_by_set_file(args).await,
"get_comparable_reports" => reports::handle_get_comparable_reports(args).await,
// Utility handlers
"check_symbol_data_status" => utility::handle_check_symbol_data_status(&self.config, args).await,
"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,
// Debugging/diagnostics handlers
"diagnose_wine" => utility::handle_diagnose_wine(&self.config, args).await,
"get_mt5_logs" => utility::handle_get_mt5_logs(&self.config, args).await,
"search_mt5_errors" => utility::handle_search_mt5_errors(&self.config, args).await,
"check_mt5_process" => utility::handle_check_mt5_process(&self.config, args).await,
"kill_mt5_process" => utility::handle_kill_mt5_process(&self.config, args).await,
"check_system_resources" => utility::handle_check_system_resources(&self.config, args).await,
"validate_mt5_config" => utility::handle_validate_mt5_config(&self.config, args).await,
"get_wine_prefix_info" => utility::handle_get_wine_prefix_info(&self.config, args).await,
"get_backtest_crash_info" => utility::handle_get_backtest_crash_info(&self.config, args).await,
_ => Ok(json!({
"content": [{ "type": "text", "text": format!("Tool '{}' not implemented", name) }],
"isError": true
+415
View File
@@ -433,3 +433,418 @@ pub async fn handle_annotate_history(args: &Value) -> Result<Value> {
"isError": false
}))
}
pub async fn handle_get_report_by_id(_config: &Config, args: &Value) -> Result<Value> {
let id = args.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("id is required"))?;
let include_chart = args.get("include_chart").and_then(|v| v.as_bool()).unwrap_or(true);
let db = ReportDb::new(&Config::db_path());
db.init()?;
match db.get_by_id(id)? {
Some(entry) => {
let mut response = json!({
"success": true,
"report": {
"id": entry.id,
"expert": entry.expert,
"symbol": entry.symbol,
"timeframe": entry.timeframe,
"from_date": entry.from_date,
"to_date": entry.to_date,
"created_at": entry.created_at,
"net_profit": entry.net_profit,
"profit_factor": entry.profit_factor,
"max_dd_pct": entry.max_dd_pct,
"sharpe_ratio": entry.sharpe_ratio,
"total_trades": entry.total_trades,
"win_rate_pct": entry.win_rate_pct,
"recovery_factor": entry.recovery_factor,
"deposit": entry.deposit,
"currency": entry.currency,
"leverage": entry.leverage,
"duration_seconds": entry.duration_seconds,
"set_file_original": entry.set_file_original,
"set_snapshot_path": entry.set_snapshot_path,
"report_dir": entry.report_dir,
"charts_dir": entry.charts_dir,
"tags": entry.tags,
"notes": entry.notes,
"verdict": entry.verdict,
}
});
if include_chart {
if let Some(charts_dir) = &entry.charts_dir {
let chart_path = Path::new(charts_dir).join("equity.png");
if chart_path.exists() {
match fs::read(&chart_path) {
Ok(bytes) => {
let base64 = BASE64.encode(&bytes);
response["report"]["equity_chart_base64"] = json!(base64);
response["report"]["equity_chart_format"] = json!("png");
}
Err(e) => {
response["report"]["equity_chart_error"] = json!(format!("Failed to read chart: {}", e));
}
}
} else {
response["report"]["equity_chart_error"] = json!("equity.png not found in charts_dir");
}
}
}
Ok(json!({
"content": [{ "type": "text", "text": response.to_string() }],
"isError": false
}))
}
None => {
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": false,
"error": format!("Report with id '{}' not found", id)
}).to_string() }],
"isError": true
}))
}
}
}
pub async fn handle_get_reports_summary(args: &Value) -> Result<Value> {
let db = ReportDb::new(&Config::db_path());
db.init()?;
let filters = ReportFilters {
expert: args.get("expert").and_then(|v| v.as_str()).map(|s| s.to_string()),
symbol: args.get("symbol").and_then(|v| v.as_str()).map(|s| s.to_string()),
timeframe: args.get("timeframe").and_then(|v| v.as_str()).map(|s| s.to_string()),
verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()),
..Default::default()
};
let stats = db.get_stats(&filters)?;
Ok(json!({
"content": [{ "type": "text", "text": json!({
"total_count": stats.total_count,
"profitable_count": stats.profitable_count,
"pass_verdict_count": stats.pass_verdict_count,
"fail_verdict_count": stats.fail_verdict_count,
"marginal_verdict_count": stats.marginal_verdict_count,
"avg_net_profit": stats.avg_net_profit,
"avg_profit_factor": stats.avg_profit_factor,
"avg_max_dd_pct": stats.avg_max_dd_pct,
"avg_win_rate_pct": stats.avg_win_rate_pct,
"avg_sharpe_ratio": stats.avg_sharpe_ratio,
"profitable_rate": if stats.total_count > 0 {
(stats.profitable_count as f64 / stats.total_count as f64 * 100.0).round()
} else { 0.0 },
"pass_rate": if stats.total_count > 0 {
(stats.pass_verdict_count as f64 / stats.total_count as f64 * 100.0).round()
} else { 0.0 },
}).to_string() }],
"isError": false
}))
}
pub async fn handle_get_best_reports(args: &Value) -> Result<Value> {
let db = ReportDb::new(&Config::db_path());
db.init()?;
let sort_by = args.get("sort_by").and_then(|v| v.as_str()).unwrap_or("profit_factor");
let order = args.get("order").and_then(|v| v.as_str()).unwrap_or("desc");
let ascending = order == "asc";
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
let filters = ReportFilters {
expert: args.get("expert").and_then(|v| v.as_str()).map(|s| s.to_string()),
symbol: args.get("symbol").and_then(|v| v.as_str()).map(|s| s.to_string()),
timeframe: args.get("timeframe").and_then(|v| v.as_str()).map(|s| s.to_string()),
verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()),
..Default::default()
};
let entries = db.get_sorted_by(sort_by, ascending, limit, &filters)?;
let reports: Vec<Value> = entries
.iter()
.map(|e| json!({
"id": e.id,
"expert": e.expert,
"symbol": e.symbol,
"timeframe": e.timeframe,
"from_date": e.from_date,
"to_date": e.to_date,
"created_at": e.created_at,
"net_profit": e.net_profit,
"profit_factor": e.profit_factor,
"max_dd_pct": e.max_dd_pct,
"sharpe_ratio": e.sharpe_ratio,
"total_trades": e.total_trades,
"win_rate_pct": e.win_rate_pct,
"set_file": e.set_file_original,
"verdict": e.verdict,
"tags": e.tags,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"sort_by": sort_by,
"order": order,
"matched": reports.len(),
"reports": reports,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_search_reports_by_tags(args: &Value) -> Result<Value> {
let tags: Vec<String> = args
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.ok_or_else(|| anyhow::anyhow!("tags array is required"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let db = ReportDb::new(&Config::db_path());
db.init()?;
let entries = db.search_by_tags(&tags, limit)?;
let reports: Vec<Value> = entries
.iter()
.map(|e| json!({
"id": e.id,
"expert": e.expert,
"symbol": e.symbol,
"timeframe": e.timeframe,
"from_date": e.from_date,
"to_date": e.to_date,
"created_at": e.created_at,
"net_profit": e.net_profit,
"profit_factor": e.profit_factor,
"max_dd_pct": e.max_dd_pct,
"total_trades": e.total_trades,
"win_rate_pct": e.win_rate_pct,
"set_file": e.set_file_original,
"verdict": e.verdict,
"tags": e.tags,
"notes": e.notes,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"tags": tags,
"matched": reports.len(),
"reports": reports,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_search_reports_by_date_range(args: &Value) -> Result<Value> {
let from_start = args.get("from_start").and_then(|v| v.as_str());
let from_end = args.get("from_end").and_then(|v| v.as_str());
let to_start = args.get("to_start").and_then(|v| v.as_str());
let to_end = args.get("to_end").and_then(|v| v.as_str());
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let db = ReportDb::new(&Config::db_path());
db.init()?;
let entries = db.search_by_date_range(from_start, from_end, to_start, to_end, limit)?;
let reports: Vec<Value> = entries
.iter()
.map(|e| json!({
"id": e.id,
"expert": e.expert,
"symbol": e.symbol,
"timeframe": e.timeframe,
"from_date": e.from_date,
"to_date": e.to_date,
"created_at": e.created_at,
"net_profit": e.net_profit,
"profit_factor": e.profit_factor,
"max_dd_pct": e.max_dd_pct,
"total_trades": e.total_trades,
"win_rate_pct": e.win_rate_pct,
"set_file": e.set_file_original,
"verdict": e.verdict,
"tags": e.tags,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"from_start": from_start,
"from_end": from_end,
"to_start": to_start,
"to_end": to_end,
"matched": reports.len(),
"reports": reports,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_search_reports_by_notes(args: &Value) -> Result<Value> {
let query = args.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("query is required"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let db = ReportDb::new(&Config::db_path());
db.init()?;
let entries = db.search_by_notes(query, limit)?;
let reports: Vec<Value> = entries
.iter()
.map(|e| json!({
"id": e.id,
"expert": e.expert,
"symbol": e.symbol,
"timeframe": e.timeframe,
"from_date": e.from_date,
"to_date": e.to_date,
"created_at": e.created_at,
"net_profit": e.net_profit,
"profit_factor": e.profit_factor,
"max_dd_pct": e.max_dd_pct,
"total_trades": e.total_trades,
"win_rate_pct": e.win_rate_pct,
"set_file": e.set_file_original,
"verdict": e.verdict,
"notes": e.notes,
"tags": e.tags,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"query": query,
"matched": reports.len(),
"reports": reports,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_get_reports_by_set_file(args: &Value) -> Result<Value> {
let set_file = args.get("set_file")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("set_file is required"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let db = ReportDb::new(&Config::db_path());
db.init()?;
let entries = db.search_by_set_file(set_file, limit)?;
let reports: Vec<Value> = entries
.iter()
.map(|e| json!({
"id": e.id,
"expert": e.expert,
"symbol": e.symbol,
"timeframe": e.timeframe,
"from_date": e.from_date,
"to_date": e.to_date,
"created_at": e.created_at,
"net_profit": e.net_profit,
"profit_factor": e.profit_factor,
"max_dd_pct": e.max_dd_pct,
"total_trades": e.total_trades,
"win_rate_pct": e.win_rate_pct,
"set_file_original": e.set_file_original,
"set_snapshot_path": e.set_snapshot_path,
"verdict": e.verdict,
"tags": e.tags,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"set_file": set_file,
"matched": reports.len(),
"reports": reports,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_get_comparable_reports(args: &Value) -> Result<Value> {
let db = ReportDb::new(&Config::db_path());
db.init()?;
// Get expert/symbol/timeframe either from report_id or direct args
let (expert, symbol, timeframe, exclude_id) = if let Some(id) = args.get("report_id").and_then(|v| v.as_str()) {
match db.get_by_id(id)? {
Some(entry) => {
let exclude = args.get("exclude_id").and_then(|v| v.as_str()).map(|s| s.to_string());
(entry.expert, entry.symbol, entry.timeframe, exclude.unwrap_or_else(|| id.to_string()))
}
None => return Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": false,
"error": format!("Report with id '{}' not found", id)
}).to_string() }],
"isError": true
}))
}
} else {
let expert = args.get("expert").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("expert or report_id is required"))?;
let symbol = args.get("symbol").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("symbol or report_id is required"))?;
let timeframe = args.get("timeframe").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("timeframe or report_id is required"))?;
let exclude_id = args.get("exclude_id").and_then(|v| v.as_str()).map(|s| s.to_string());
(expert.to_string(), symbol.to_string(), timeframe.to_string(), exclude_id.unwrap_or_default())
};
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let exclude_opt = if exclude_id.is_empty() { None } else { Some(exclude_id.as_str()) };
let entries = db.get_comparable(&expert, &symbol, &timeframe, exclude_opt, limit)?;
let reports: Vec<Value> = entries
.iter()
.map(|e| json!({
"id": e.id,
"expert": e.expert,
"symbol": e.symbol,
"timeframe": e.timeframe,
"from_date": e.from_date,
"to_date": e.to_date,
"created_at": e.created_at,
"net_profit": e.net_profit,
"profit_factor": e.profit_factor,
"max_dd_pct": e.max_dd_pct,
"total_trades": e.total_trades,
"win_rate_pct": e.win_rate_pct,
"set_file": e.set_file_original,
"verdict": e.verdict,
"tags": e.tags,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"expert": expert,
"symbol": symbol,
"timeframe": timeframe,
"exclude_id": exclude_id,
"matched": reports.len(),
"reports": reports,
}).to_string() }],
"isError": false
}))
}
-1
View File
@@ -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> {
+67 -6
View File
@@ -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
}))
File diff suppressed because it is too large Load Diff
+144
View File
@@ -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