15 Commits

Author SHA1 Message Date
Devid HW 0dc5ce0c0c fix: allow dirty checkout in cargo publish step 2026-06-25 16:15:46 +07:00
Devid HW 1d817c9bed release: v1.33.0 2026-06-25 16:04:22 +07:00
Devid HW 17bb7545ba feat: overhaul optimizer launch, rewrite set file parsing, add OS detection and utils 2026-06-25 16:03:59 +07:00
Devid HW 52c3d91639 chore: add .ocx, .opencode, and AGENTS.md to gitignore 2026-06-25 15:23:23 +07:00
Devid HW e954883189 chore: simplify docs for LLM automation, remove platform-specific install steps
- Rewrite README.md to focus on LLM-driven install setup
- Simplify QUICKSTART.md to minimal LLM instruction guide
- Rewrite CONFIG.md to remove platform-specific path examples
- Delete platform-specific IDE docs (CLAUDE.md, CURSOR.md, VSCODE.md, WINDSURF.md)
- Simplify REMOTE_AGENTS.md to remove Wine install details
- Clean up TROUBLESHOOTING.md to remove platform install sections
- Ignore .vscode/ and .codegraph/ directories
- Untrack server.json (should remain tracked - already restored)
2026-06-25 14:49:30 +07:00
Devid HW 50025968ff feat: add update tool definitions 2026-05-17 05:44:31 +07:00
github-actions[bot] 6ccc066b6a ci: update server.json SHA256 for v1.32.4 [skip ci] 2026-04-25 02:17:47 +00:00
Devid HW 3ad67d4c57 docs: fix inaccuracies found during documentation accuracy review
- CHANGELOG: inactivity_kill_secs default is disabled (None), not 120
- TROUBLESHOOTING: clarify inactivity_kill_secs is opt-in (must be
  passed explicitly); correct "default 120" claim
- MCP_TOOLS: inactivity_kill_secs is disabled by default; add note
  that Wine/macOS may not exit naturally even with ShutdownTerminal=1
- handlers/backtest.rs: remove stale comment claiming inactivity
  watchdog is "intentionally skipped when shutdown=true" — replaced
  with accurate description of the new 30s HTML-wait + kill behavior

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 09:14:44 +07:00
Devid HW 4c34ae236c docs: update CHANGELOG, TROUBLESHOOTING, MCP_TOOLS for v1.32.4
- CHANGELOG: add v1.32.4 entry with all fixes and verification results
- TROUBLESHOOTING: add Wine/macOS HTML report section (ShutdownTerminal=1
  does not exit terminal64.exe; inactivity kill + 30s HTML wait workaround)
- TROUBLESHOOTING: add list_deals 0 results section (binary restart required)
- MCP_TOOLS: add shutdown and inactivity_kill_secs params to launch_backtest
  schema (were implemented but undocumented)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 09:11:37 +07:00
Devid HW ecf5606b7a fix: HTML report extraction and backtest pipeline reliability
- fix: inactivity watchdog now waits 30s for HTML report then kills
  terminal64.exe unconditionally — ShutdownTerminal=1 does not cause
  MT5 to exit on Wine/macOS; relying on natural exit caused all runs
  to fall back to journal extraction
- fix: tester agent log deduplication — each deal is written twice in
  the log; use HashSet to skip already-seen deal numbers
- fix: position tracker for entry direction inference — infer "in"/"out"
  from per-symbol signed lot accumulation instead of guessing
- fix: is_closed_trade() no longer gates on profit!=0.0 — journal deals
  have profit=0.0 legitimately; the old gate hid all 140 deals from
  list_deals
- fix: log file selection priority — prefer Agent-127.0.0.1 over
  Agent-0.0.0.0 (startup-only log), tiebreak by file size
- feat: launch_backtest default shutdown=true; inactivity_kill_secs
  default 120s exposed as tool parameter
- feat: report DB stores deals in SQLite; analytics resolve by
  report_id / report_dir / latest

Verified: 1-month DPS21/XAUUSD.cent/M5 backtest produces full HTML
report with all metrics (win_rate=70%, profit_factor=0.77, sharpe=-3.61,
max_dd=6.93%) and all 17 analytics tools returning real data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 08:53:17 +07:00
Devid 6bfb330b5d Update funding sources in FUNDING.yml 2026-04-23 01:44:31 +07:00
Devid 52a3d44b99 Add funding options for GitHub and PayPal
Updated funding options to include GitHub sponsor and custom PayPal link.
2026-04-23 01:42:55 +07:00
Devid caabc2c03b Change Dependabot update interval to monthly 2026-04-23 01:39:27 +07:00
Devid HW 62c1ca773e ci: publish to crates.io before GitHub release
Add cargo-publish job that runs in parallel with binary builds.
The release job now depends on [build-macos, build-linux, cargo-publish]
so the crate is always published to crates.io before the GitHub release
and binaries are attached.

Requires CARGO_REGISTRY_TOKEN secret in repo settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:30:23 +07:00
github-actions[bot] 3b9d4ce2dd ci: update server.json SHA256 for v1.32.3 [skip ci] 2026-04-22 18:28:53 +00:00
37 changed files with 1948 additions and 1312 deletions
+4
View File
@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: masdevid
custom: ["https://www.paypal.me/devidhw", "https://saweria.co/depod"]
+11
View File
@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "monthly"
+27 -1
View File
@@ -88,10 +88,36 @@ jobs:
name: linux-binary
path: dist/mcp-mt5-quant-linux-x64.tar.gz
# ── Cargo Publish ────────────────────────────────────────────────────────────
cargo-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: cargo-publish-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-publish-
- name: Publish to crates.io
run: cargo publish --no-verify --allow-dirty
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# ── GitHub Release ───────────────────────────────────────────────────────────
release:
needs: [build-macos, build-linux]
needs: [build-macos, build-linux, cargo-publish]
runs-on: ubuntu-latest
permissions:
contents: write
+11 -2
View File
@@ -19,13 +19,22 @@ config/mt5-quant.yaml
config/baseline.json
config/backtest_history.json
# Claude Code (personal, not for repo)
/CLAUDE.md
# Agent configs (personal LLM instructions)
CLAUDE.md
.claude/
.ocx
.opencode
AGENTS.md
# Windsurf (local workflows, not for repo)
.windsurf/
# VS Code
.vscode/
# Codegraph
.codegraph/
# macOS
.DS_Store
-8
View File
@@ -1,8 +0,0 @@
{
"json.schemas": [
{
"fileMatch": ["server.json"],
"url": "https://raw.githubusercontent.com/modelcontextprotocol/specification/main/schema/2024-11-05/schema.json"
}
]
}
+245
View File
@@ -0,0 +1,245 @@
# AGENTS.md — Developer instructions for MT5-Quant
## Project Overview
Open-source MCP server for MetaTrader 5 backtesting, optimization, and analytics — written entirely in Rust. Exposes 89 stdio MCP tools for compiling MQL5 EAs, running backtests via Wine, extracting deal-level data, organizing reports in SQLite, and performing 19 dimensions of analysis.
Target users: MQL developers on macOS (CrossOver or native MetaTrader5.app) and Linux (Wine/Xvfb).
## Repository Layout
```
MT5-Quant/
├── src/
│ ├── main.rs # CLI entry + stdio MCP transport
│ ├── mcp_server.rs # McpServer struct, tool dispatch, init/notification
│ ├── mt5.rs # Wine/MT5 executable launchers
│ ├── models/
│ │ ├── mod.rs
│ │ ├── config.rs # Config, CurrentAccount, wine detection, .set helpers
│ │ ├── deals.rs # Deal, PositionPair, DrawdownEvent
│ │ ├── metrics.rs # BacktestMetrics parsing (HTML + XML formats)
│ │ └── report.rs # ReportEntry, PipelineMetadata, BacktestJob, status
│ ├── analytics/
│ │ ├── mod.rs
│ │ ├── extract.rs # HTML/XML report → Deal[] + metrics
│ │ └── analyze.rs # DealAnalyzer: 19+ analysis methods
│ ├── compile/
│ │ ├── mod.rs
│ │ └── mql_compiler.rs # MetaEditor compiler via Wine
│ ├── pipeline/
│ │ ├── mod.rs
│ │ ├── backtest.rs # 5-stage pipeline: COMPILE→CLEAN→BACKTEST→EXTRACT→ANALYZE
│ │ └── stages.rs # PipelineStage enum
│ ├── optimization/
│ │ ├── mod.rs
│ │ ├── optimizer.rs # Background optimization launcher
│ │ └── parser.rs # Optimization result XML → best params
│ ├── storage/
│ │ ├── mod.rs
│ │ └── database.rs # ReportDb: reports + deals SQLite tables, queries
│ ├── utils/ # (new) shared utilities — growing directory
│ └── tools/
│ ├── mod.rs # MCP definitions aggregation
│ ├── definitions/ # Tool schemas (JSON Schema) — 11 modules, 90 tools
│ │ ├── mod.rs # exports: all_tools(), system_tools, etc.
│ │ ├── analytics.rs # 19 analysis tools + compare_baseline
│ │ ├── backtest.rs # 7 backtest tools
│ │ ├── baseline.rs # 1 baseline tool
│ │ ├── experts.rs # 9 EA/indicator/script tools
│ │ ├── optimization.rs # 4 optimization tools
│ │ ├── reports.rs # 20 report management tools
│ │ ├── setfiles.rs # 8 .set file tools
│ │ ├── system.rs # 6 system/health tools + version update
│ │ ├── update.rs # update tool definition
│ │ └── utility.rs # 10 pre-flight/compat tools
│ └── handlers/ # Tool dispatch functions — 11 modules
│ ├── mod.rs # ToolHandler struct, dispatch table (89+ cases)
│ ├── analysis.rs # analytics/analyze dispatch
│ ├── backtest.rs # pipeline/backtest dispatch
│ ├── experts.rs # wine + WalkDir helpers: scan_mql_dir, copy_mql_to_project
│ ├── optimization.rs # optimizer dispatch
│ ├── reports.rs # ReportDb + ReportEntry dispatch
│ ├── setfiles.rs # UTF-16LE .set read/write/patch helpers
│ ├── system.rs # healthcheck, version update
│ ├── utility.rs # pre-flight, diagnostics, init_project helpers
│ └── update.rs # update tool handler
├── scripts/
│ ├── setup.sh # Auto-detect Wine/MT5, write config (1271 lines)
│ ├── platform_detect.sh # Wine path + headless detection
│ ├── build-rust.sh # cargo build --release (25 lines)
│ ├── release.sh # Version bump + changelog + tag + push (238 lines)
│ └── optimize.sh # Legacy optimization driver (pending full Rust migration)
├── analytics/ # Legacy Python (reference only — do not modify)
├── config/
│ ├── mt5-quant.example.yaml # Template user copies to mt5-quant.yaml
│ └── mt5-quant.yaml # Generated config (gitignored)
├── tests/
│ ├── test_mcp.py # End-to-end MCP tests (stdio-driven)
│ ├── integration_test.sh # Shell-based integration tests
│ └── fixtures/ # Sample reports + CSVs for testing
├── server.json # MCP registry manifest (tracked)
├── Cargo.toml # version = 1.32.4
└── CHANGELOG.md
```
## Key Design Constraints
- **Headless/GUI**: `platform_detect.sh` auto-selects. macOS CrossOver = GUI (CrossOver abstracts display). Linux without `$DISPLAY` = Xvfb. Controlled by `display.mode` in config (`auto` | `gui` | `headless`).
- **Single MT5 instance**: Never run two backtests in parallel on the same Wine prefix.
- **Model 0 only for optimization**: Model 1 overfits martingale/grid EAs — intra-bar movement not simulated. Use `model=0` (every tick).
- **UTF-16LE .set files**: MT5 strips `||Y` flags from UTF-8. All `.set` writes use UTF-16LE + `chmod 444`. See `setfiles.rs` helpers.
- **OptMode reset**: After any optimization, `terminal.ini` gets `OptMode=-1`. Must reset to `0` before next backtest (handled in `backtest.rs` cleanup stage).
- **Report format detection**: MT5 Build 48+ writes SpreadsheetML XML (`.htm.xml`), not HTML. Both formats handled in `extract.rs` — always check for the XML variant first.
- **EA-agnostic**: Never hardcode magic numbers, strategy names, or assume specific EA behavior. All code must work with any MQL5 EA.
## Architecture
1. **CLI (`main.rs`)** — parses CLi args (`--stdio`, `--port`, `--test-launch`), initializes `McpServer`, runs stdio or TCP transport loop, handles JSON-RPC requests.
2. **McpServer (`mcp_server.rs`)** — wraps `ToolHandler` with `Arc<Mutex>`, handles `initialize`/`notifications`/`tool/error` method routing. Auto-verifies Wine/MT5 on first call.
3. **ToolHandler (`tools/handlers/mod.rs`)** — dispatch table mapping 89 tool names to handler functions. Each handler module is self-contained.
4. **Definitions (`tools/definitions/`)** — JSON Schema objects for tool input/output. Never executes logic.
5. **Analytics (`analytics/`)** — two core structs:
- `ReportExtractor` — reads HTML/XML report + tester log → `Vec<Deal>` + `BacktestMetrics`
- `DealAnalyzer` — takes `Vec<Deal>` → 19 analysis methods (monthly PnL, drawdown events, streaks, etc.)
6. **Pipeline (`pipeline/backtest.rs`)**`BacktestPipeline` struct with 5 stages:
- `COMPILE``MqlCompiler::compile()` via MetaEditor
- `CLEAN` — clean cache, reset OptMode
- `BACKTEST` — launch MT5 tester via Wine, poll status
- `EXTRACT` — parse HTML/XML → deals + metrics
- `ANALYZE` — run all analytics, write to SQLite
7. **Storage (`storage/database.rs`)**`ReportDb` with two tables:
- `reports` — metrics, set file paths, verdict, tags, notes
- `deals` — individual deal rows, keyed by `report_id`
- `CREATE TABLE IF NOT EXISTS` in `init()` — idempotent on startup
## Building
```bash
bash scripts/build-rust.sh
# or
cargo build --release
```
Binary: `target/release/mt5-quant`
## Testing
- **Unit/integration:** `cargo test`
- **End-to-end MCP:** `python3 tests/test_mcp.py` — drives the MCP server over stdio (full backtest + all analytics)
- **Analytics only:** No MT5/Wine needed; loads deals from SQLite DB via `db.get_deals(report_id)`
- **Symbol note:** Testing account uses `XAUUSDc` (broker suffix required), not `XAUUSD`
## Configuration
Users copy `config/mt5-quant.example.yaml``config/mt5-quant.yaml` (gitignored).
Run `bash scripts/setup.sh` to auto-detect Wine, MT5 paths, architecture, and write config.
Minimum config:
```yaml
wine_executable: "/path/to/wine64"
terminal_dir: "/path/to/MetaTrader 5"
```
Optional:
```yaml
defaults:
symbol: "XAUUSD.cent"
timeframe: "M5"
deposit: 10000
display:
mode: auto # auto | gui | headless
reports_dir: "./reports"
experts_dir: "~/.../MQL5/Experts"
```
## Developing New Tools
Each new tool follows a 4-file pattern:
1. **Schema** — Add tool input/output to `tools/definitions/{domain}.rs` via `Tool::new(name) {...}` with `Input::schema` and `Output::schema`.
2. **Export** — Add to `tools/definitions/mod.rs`:
- Include in `all_tools()` array
- Add to domain exports if creating new domain
3. **Handler** — Add function in `tools/handlers/{domain}.rs`:
```rust
pub async fn handle_tool_name(config: &Config, args: &Value) -> Result<Value> {
let required = required_str(args, "param_name")?;
ok_response(json! { "result": "..." })
}
```
4. **Dispatch** — Add case in `handlers/mod.rs` match arm under `ToolHandler::handle()`.
### Handler Helper Functions (use instead of inline logic)
| Helper | Purpose |
|--------|---------|
| `required_str(args, key)` | Extract required `&str` or return error |
| `ok_response(data)` / `err_response(msg)` | Wrap in MCP content envelope |
| `resolve_report(args)` | → `(deals, metrics, report_dir)` — `report_id > report_dir > latest` |
| `prepare_analysis(args)` | → `(deals, metrics, analyzer, report_dir)` — wraps resolve_report |
| `scan_mql_dir(dir, filter, type_label)` | WalkDir with optional filter for list/search |
| `copy_mql_to_project(config, args, fallback)` | Shared copy logic for indicator/script |
Extend helpers when adding new handlers.
## Commit Style
Conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `release:`
Keep commits focused — one logical change per commit.
## Release Process
```bash
bash scripts/release.sh <patch|minor|major|X.Y.Z> [--yes]
```
Script handles:
1. Bump `Cargo.toml` + `Cargo.lock` version
2. Update `server.json` (version + SHA256 placeholder)
3. Generate CHANGELOG entry from git log
4. `cargo check --quiet`
5. Commit `release: vX.Y.Z`
6. Tag `vX.Y.Z`
7. Push — CI (`release.yml`) triggered:
- Build macOS arm64 + Linux x64 binaries
- Publish to crates.io
- Create GitHub Release with artifacts
- Build MCP package, compute SHA256
- Update `server.json` SHA256, push
- Publish to MCP Registry
## Deal Storage
- Deals stored in `deals` SQLite table, keyed by `report_id`. **No `deals.csv` written automatically.**
- `db.insert_deals(report_id, &[Deal])` called in pipeline after extraction.
- `db.get_deals(report_id)` loads deals for analytics. `db.get_by_report_dir(path)` resolves by filesystem path.
- `export_deals_csv` tool writes CSV on demand.
## DB Schema Evolution
Add new tables/columns in `ReportDb::init()` using `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` — runs on every startup, is idempotent. No migration files needed.
## Wine / MT5 Notes
- Wine executable paths differ across platforms:
- macOS MT5.app: `/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64`
- macOS CrossOver: `/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine64`
- Linux: `/usr/bin/wine64` (or first `wine64` on PATH)
- MT5 terminal_dir is auto-resolved from wine_executable path.
- Apple Silicon: append `arch -x86_64` prefix for x86 Wine binaries (auto-detected).
## Common Pitfalls
- **M1/Memory issues**: Wine on Apple Silicon runs x86 via Rosetta 2. Watch memory pressure.
- **Model mismatch**: Grid/martingale EAs need `model=0` (every tick). Model 1/2 will produce zero deals.
- **Set file encoding**: Always use UTF-16LE with `chmod 444`. UTF-8 strips `||Y` flags.
- **OptMode=-1**: Must clear after optimization. Handled in pipeline cleanup stage.
- **Report not found**: Check `<terminal_dir>/history/` for correct symbol name (broker suffix matters: `XAUUSDc` vs `XAUUSD`).
- **Journal-only backtest**: `ShutdownTerminal=1` doesn't exit MT5 on Wine. Inactivity watchdog (in `launch_backtest`) handles this with `inactivity_kill_secs` param.
+45
View File
@@ -1,5 +1,50 @@
# Changelog
## [1.33.0] — 2026-06-25
### Added
- **`src/utils/` module**: shared `read_file_as_utf8` utility for UTF-16LE BOM detection, used across set file and config handlers.
- **OS detection and diagnostics**: `utility.rs` gains `OsType` enum, `get_os_context`, `get_system_memory`, and `crossover_server_running` helper for better CrossOver diagnostics.
- **Wine binary detection**: MT5.app and CrossOver now detect both `wine` and `wine64` binaries; Homebrew and Linux paths add `wine` alongside `wine64`.
### Changed
- **Optimizer launch mechanism**: rewritten from `/mt5mcp_backtest.ini` + `.bat` to `/config:` INI + shell script. Now patches `terminal.ini` directly with full optimization parameters and appends agent configuration — no longer relies on batch file or separate INI.
- **OptMode reset**: removed as a separate step; now included inline in `terminal.ini` patching during optimizer startup.
- **Wine prefix resolution**: changed from 2-parent to 3-parent traversal, matching the backtest pipeline's Wine prefix logic.
- **Set file parsing**: parameter delimiter changed from `:` to `=` (matches actual MT5 .set format); `||Y` sweep param parsing rewritten to split on `||` for accurate range extraction.
- **OptimizationParams**: `model` parameter removed (was hardcoded to `0` everywhere anyway; MT5 now defaults correctly).
### Fixed
- **Set file cross-platform reading**: `.set` files are now read as UTF-16LE (with BOM) or UTF-8 regardless of platform, fixing potential encoding issues on macOS/Linux.
- **Read-only set file overwrite**: handles pre-existing read-only files during `.set` write by removing them first.
## [1.32.4] — 2026-04-25
### Fixed
- **HTML report extraction**: `ShutdownTerminal=1` does not cause `terminal64.exe` to exit on Wine/macOS.
Inactivity watchdog now waits 30 s for the report file after the tester log goes quiet, then kills
MT5 unconditionally — no longer depends on natural process exit.
- **Duplicate deals**: tester agent log writes each deal twice; deduplicated via `HashSet<deal_number>`
in `parse_journal_deals`.
- **Entry direction inference**: all journal deals were marked `entry="in"` because no direction info
exists in the log. Fixed with a per-symbol position tracker (signed lot accumulation) to infer
"in" / "out" correctly.
- **`list_deals` returning 0 results**: `is_closed_trade()` was gating on `profit != 0.0`; journal
deals legitimately have zero profit. Removed the profit gate.
- **Wrong tester log selected**: `Agent-0.0.0.0` logs only contain startup lines, not deal lines.
`find_active_tester_agent_log` now prefers `Agent-127.0.0.1` and tiebreaks by file size.
### Added
- `launch_backtest` exposes `shutdown` (default `true`) and `inactivity_kill_secs` (default: disabled;
set to e.g. `120` to enable the inactivity watchdog) as explicit tool parameters.
- Report DB stores deals in SQLite; all analytics tools resolve by `report_id` / `report_dir` / latest.
### Verified
- 1-month DPS21/XAUUSD.cent/M5 backtest produces full HTML report: win_rate=70%,
profit_factor=0.77, sharpe=-3.61, max_dd=6.93%. All 17 analytics tools return real data.
## [1.31.5] — 2026-04-22
- feat: add check_update and update tools with background auto-check
Generated
+1 -1
View File
@@ -481,7 +481,7 @@ dependencies = [
[[package]]
name = "mt5-quant"
version = "1.32.3"
version = "1.32.4"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mt5-quant"
version = "1.32.3"
version = "1.33.0"
edition = "2021"
description = "MCP server for MT5 strategy development on macOS/Linux"
authors = ["masdevid <masdevid@example.com>"]
+28 -78
View File
@@ -27,83 +27,33 @@ Claude: [compile → clean → backtest → analyze 1,847 deals]
**Others** typically run on Windows using the [MetaTrader5 Python package](https://pypi.org/project/MetaTrader5/), providing full terminal operations. MT5-Quant fills the gap: **organizing backtest reports, extracting deal-level insights, and managing optimization workflows** — none of which MT5 or its Python API expose natively.
## Why Rust
**MT5-Quant is built entirely in Rust** — one static binary, zero dependencies, instant startup.
| | Rust | Python/Node |
|---|---|---|
| **Startup** | ~10ms | 200500ms |
| **Deploy** | Single binary | venv + pip/npm |
| **Memory** | <50MB, no GC pauses | Unpredictable spikes |
| **Safety** | Compile-time guaranteed | Runtime exceptions |
**Why this matters for trading:**
- **No garbage collection** — Process 100k+ deal rows without GC pauses during live analysis
- **Async via Tokio** — Handle multiple tool calls concurrently (poll status while streaming logs)
- **Cross-platform** — Same source compiles to native macOS arm64 and Linux x86_64
- **Type-safe pipelines** — MQL5 compilation, Wine path handling, SQLite queries: all checked at compile time
## Quick Install
### Option 1: Pre-built Binary (Recommended)
```bash
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
tar -xzf mt5.tar.gz
bash scripts/setup.sh
```
### Option 2: Cargo Install (if you have Rust)
```bash
cargo install mt5-quant
mt5-quant --help
```
Compiles from source. Takes 25 minutes. Requires Rust 1.70+.
### 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) |
| **Claude Desktop** | Agent Panel → ... → MCP Servers → Edit configuration | [CLAUDE.md →](docs/CLAUDE.md) |
> **Note:** Use absolute paths like `/Users/name/mt5-quant/mt5-quant` or `$(pwd)/mt5-quant`, not relative paths like `./mt5-quant`.
## Quick Start
### Install MT5-Quant
Ask your LLM coding platform to install and configure MT5-Quant:
> "Please install mt5-quant. It's a Rust-based MCP server for MT5 backtesting at https://github.com/masdevid/mt5-quant. Run its `scripts/setup.sh` to auto-detect Wine and MT5 paths, register the MCP server, and configure everything."
The LLM will:
1. Download the pre-built binary (or `cargo build --release`)
2. Run `scripts/setup.sh` to auto-detect Wine/MT5 and write config
3. Register the MCP server with your coding platform
### First Backtest
```
Run a backtest on MyEA from 2025.01.01 to 2025.03.31
```
The AI runs the full pipeline: compile → clean cache → backtest → extract → analyze.
## Documentation
| 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 |
| [CLAUDE.md](docs/CLAUDE.md) | Claude Desktop setup |
| [CONFIG.md](docs/CONFIG.md) | Configuration reference |
| [TOOLS.md](docs/MCP_TOOLS.md) | All 89 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 (89)
### Core workflow
| Tool | Description |
|------|-------------|
|------|-----|
| `run_backtest` | Full pipeline: compile → clean → backtest → extract → analyze |
| `run_backtest_quick` | Quick backtest using pre-compiled EA (skip compile) |
| `run_backtest_only` | Backtest only - just extract raw trades, no analysis |
@@ -124,7 +74,7 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
### Granular Analytics (individual analysis)
| Tool | Description |
|------|-------------|
|------|-----|
| `analyze_monthly_pnl` | Monthly P/L breakdown only |
| `analyze_drawdown_events` | Drawdown events and causes only |
| `analyze_top_losses` | Worst losing deals only |
@@ -139,7 +89,7 @@ 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 |
@@ -154,7 +104,7 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
### Monitoring
| Tool | Description |
|------|-------------|
|------|-----|
| `verify_setup` | Check Wine/MT5 paths, Wine version, and EA/set file counts |
| `get_optimization_status` | Check live state of a background optimization job |
| `list_jobs` | All optimization jobs with compact status in one call |
@@ -162,7 +112,7 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
### Reports & logs
| Tool | Description |
|------|-------------|
|------|-----|
| `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 |
@@ -181,7 +131,7 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
### History & baseline
| Tool | Description |
|------|-------------|
|------|-----|
| `archive_report` | Convert one report dir → compact JSON entry in `backtest_history.json`, optionally delete source |
| `archive_all_reports` | Bulk-archive all report dirs then optionally delete them; keeps N newest safe |
| `get_history` | Query history with filters (EA, symbol, verdict, profit, DD) and sort options |
@@ -190,14 +140,14 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
### Cache management
| Tool | Description |
|------|-------------|
|------|-----|
| `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 |
@@ -206,7 +156,7 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
### 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) |
@@ -222,7 +172,7 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
### 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 |
@@ -230,14 +180,14 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
### 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 |
|------|-------------|
|------|-----|
| `list_set_files` | All .set files in tester profiles dir with sweep stats and combination counts |
| `read_set_file` | Parse UTF-16LE `.set` file → structured JSON params |
| `write_set_file` | Write full params dict → UTF-16LE `.set` with `chmod 444` |
@@ -247,7 +197,7 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
### .set file — analysis & generation
| Tool | Description |
|------|-------------|
|------|-----|
| `describe_sweep` | Swept params, value counts, and total optimization combinations |
| `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 |
@@ -255,7 +205,7 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
### 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 |
@@ -266,7 +216,7 @@ 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.
Run `verify_setup` from your LLM 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
-142
View File
@@ -1,142 +0,0 @@
# Claude Desktop MCP Integration Setup
## Quick Setup
### Option 1: Install via MCP Registry (Recommended)
Search for `mt5-quant` in Claude Desktop's MCP manager, or add directly to your config.
### Option 2: Download Prebuilt Binary
```bash
# macOS (Apple Silicon)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-quant/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-quant/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 3: Build from Source
```bash
git clone https://github.com/masdevid/mt5-quant
cd mt5-quant
cargo build --release
```
## Configure Claude Desktop
### Step 1: Open MCP Configuration
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows).
### Step 2: Add mt5-quant
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/absolute/path/to/mt5-quant"
}
}
}
```
### Step 3: Reload and Verify
1. Save the file
2. **Restart Claude Desktop**
3. In a new conversation, 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/mcp-server/bin/mt5-quant"
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${env:GITHUB_TOKEN}"
}
}
}
}
```
**Path notes:**
- Prebuilt binary: `/Users/name/mt5-quant/mcp-server/bin/mt5-quant` (extracted from release tarball)
- Dev build: `/Users/name/mt5-quant/target/release/mt5-quant` (after `cargo build --release`)
## Environment Variables
Claude Desktop supports `${env:VAR_NAME}` syntax for environment variable substitution:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/name/mt5-quant/mcp-server/bin/mt5-quant",
"env": {
"MT5_MCP_HOME": "${env:HOME}/.config/mt5-quant"
}
}
}
}
```
## Verify Setup
In a Claude Desktop conversation, 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
### "Tool not found" or server not appearing
1. Double-check the absolute path in `claude_desktop_config.json`
2. Ensure the binary has execute permissions: `chmod +x /path/to/mt5-quant`
3. Restart Claude Desktop completely
4. Check Claude Desktop logs: `~/Library/Logs/Claude/` (macOS)
### JSON syntax errors
1. Validate the JSON at [jsonlint.com](https://jsonlint.com)
2. Ensure no trailing commas
3. Use absolute paths (not `~` or relative paths)
### Agent hallucinating tool parameters
1. Verify the tool is available: "List your available MCP tools"
2. Start a new conversation
3. Be explicit: "Use the `run_backtest` tool with expert=MyEA"
## Configuration Location
| Platform | Path |
|----------|------|
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
## Resources
- [Claude Desktop MCP Documentation](https://docs.anthropic.com/en/docs/claude-code/mcp)
- [MCP Server Reference](https://github.com/modelcontextprotocol/servers)
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
+4 -51
View File
@@ -1,18 +1,15 @@
# Configuration Reference
## Config File Location
## Config File
```
config/mt5-quant.yaml # Project directory (development)
~/.config/mt5-quant/config/mt5-quant.yaml # System-wide (production)
```
Environment variable to override:
```bash
export MT5_MCP_HOME=/path/to/config
```
Override with env: `export MT5_MCP_HOME=/path/to/config`
## Full Config Example
## Full Example
```yaml
# Required: Wine executable path
@@ -52,53 +49,9 @@ optimization:
min_agents: 4
```
## Platform-Specific Examples
### macOS with MetaTrader 5.app
```yaml
wine_executable: "/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"
terminal_dir: "~/Library/Application Support/net.metaquotes.wine.metatrader5/drive_c/Program Files/MetaTrader 5"
defaults:
symbol: "XAUUSDc"
timeframe: "M5"
deposit: 10000
```
### macOS with CrossOver
```yaml
wine_executable: "/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine64"
terminal_dir: "~/Library/Application Support/MetaQuotes/Terminal/<hash>/drive_c/Program Files/MetaTrader 5"
```
### Linux
```yaml
wine_executable: "/usr/bin/wine64"
terminal_dir: "~/.wine/drive_c/Program Files/MetaTrader 5"
display:
mode: headless
xvfb_display: ":99"
```
## Headless Mode (Linux VPS)
```yaml
display:
mode: headless
xvfb_display: ":99"
xvfb_screen: "1024x768x16"
```
Requires:
```bash
sudo apt install xvfb
```
## Auto-Detection
`setup.sh` automatically detects:
`setup.sh` detects:
- Wine executable (MetaTrader 5.app, CrossOver, or system Wine)
- MT5 terminal directory
- Architecture (Apple Silicon adds `arch -x86_64`)
-129
View File
@@ -1,129 +0,0 @@
# Cursor MCP Integration Setup
## Quick Setup
### Option 1: Download Prebuilt Binary (Recommended)
```bash
# macOS (Apple Silicon)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
tar -xzf mt5.tar.gz
# Linux (x64)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
tar -xzf mt5.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**: `/path/to/mt5-quant/mcp-server/bin/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": "/path/to/mt5-quant/mcp-server/bin/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/mcp-server/bin/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/mcp-server/bin/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}/mt5-quant/mcp-server/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)
+12 -4
View File
@@ -185,10 +185,18 @@ Fire-and-forget mode: compile → clean → launch MT5 backtest, return immediat
deposit?: number; // Initial deposit (default: 10000)
model?: 0 | 1 | 2; // Tick model (default: 0)
set_file?: string; // Path to .set parameter file
skip_compile?: boolean; // Skip compilation
skip_clean?: boolean; // Skip cache cleaning
timeout?: number; // Max time in seconds (default: 900)
gui?: boolean; // Enable MT5 visualization
skip_compile?: boolean; // Skip compilation
skip_clean?: boolean; // Skip cache cleaning
timeout?: number; // Max time in seconds (default: 900)
gui?: boolean; // Enable MT5 visualization
shutdown?: boolean; // Shut down MT5 after test (default: true).
// NOTE: on Wine/macOS terminal64.exe may not exit naturally
// even with ShutdownTerminal=1. Use inactivity_kill_secs too.
inactivity_kill_secs?: number; // Kill MT5 if tester log hasn't grown for N seconds
// (default: disabled / not set). Recommended: 120.
// After silence, pipeline polls for HTML report for 30s,
// then kills MT5 unconditionally. If HTML present → extracted;
// otherwise falls back to journal extraction (no P&L data).
}
```
+18 -166
View File
@@ -1,192 +1,44 @@
# Quickstart Guide
## 1. Download or Build
For end-users wanting full platform-specific steps, see the [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
### Option A: Prebuilt Binary (Recommended)
## Install
```bash
# macOS (Apple Silicon)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
tar -xzf mt5.tar.gz
Ask your LLM platform to install MT5-Quant:
# Linux (x64)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
tar -xzf mt5.tar.gz
```
> "Please install mt5-quant from https://github.com/masdevid/mt5-quant. Download the pre-built binary, run `scripts/setup.sh` to auto-detect Wine/MT5 paths, and register the MCP server."
### Option B: Build from Source
The LLM will handle:
1. Download binary or `cargo build --release`
2. Run setup script to auto-detect Wine and MT5 paths
3. Write `config/mt5-quant.yaml` (gitignored)
4. Register MCP server with your platform
```bash
git clone https://github.com/masdevid/mt5-quant
cd mt5-quant
bash scripts/build-rust.sh
```
## Minimal Config
## 2. Install MetaTrader 5
If manual config is needed, `config/mt5-quant.yaml` requires only:
### macOS - MetaTrader 5.app (Free)
1. Download from [metatrader5.com](https://www.metatrader5.com/en/download)
2. Install to `/Applications`
3. **Launch once** to initialize Wine prefix (~30s), then quit
Auto-detected paths:
- Wine: `/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64`
- MT5: `~/Library/Application Support/net.metaquotes.wine.metatrader5/drive_c/Program Files/MetaTrader 5`
### macOS - CrossOver (Paid, Better Compatibility)
1. Install [CrossOver](https://www.codeweavers.com/)
2. Create bottle `MetaTrader5`
3. Install MT5 inside bottle
Auto-detected paths:
- Wine: `/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine64`
- MT5: `~/Library/Application Support/MetaQuotes/<hash>/drive_c/Program Files/MetaTrader 5`
### Linux
```bash
# Debian/Ubuntu
sudo apt install wine64 xvfb
# Fedora/RHEL
sudo dnf install wine xorg-x11-server-Xvfb
# Install MT5
wine64 MetaTrader5Setup.exe
```
MT5 location: `~/.wine/drive_c/Program Files/MetaTrader 5`
## 3. Configure
Run the setup script to auto-detect paths:
```bash
bash scripts/setup.sh # interactive
bash scripts/setup.sh --yes # non-interactive (CI)
```
This creates `config/mt5-quant.yaml` (gitignored).
Minimum config:
```yaml
wine_executable: "/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"
terminal_dir: "~/Library/Application Support/net.metaquotes.wine.metatrader5/drive_c/Program Files/MetaTrader 5"
wine_executable: "/path/to/wine64"
terminal_dir: "/path/to/MetaTrader 5"
```
## 4. Register MCP Server
`setup.sh` auto-detects both.
### Claude Code
## Verify
```bash
claude mcp add io.github.masdevid/mt5-quant -- ~/.local/bin/mt5-quant
# Or: claude mcp add mt5-quant -- $(pwd)/mcp-server/bin/mt5-quant
```
Verify:
```bash
claude mcp list
```
### Windsurf
Add to `~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
```
### Cursor
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/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/mcp-server/bin/mt5-quant"
}
}
}
```
Or run `MCP: Add Server` from Command Palette.
### Claude Desktop
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. Restart Claude Desktop to apply changes
## 5. Verify Setup
```bash
bash scripts/platform_detect.sh
```
Or in Claude/Windsurf:
```
Run verify_setup
```
Expected output:
```
Wine: /Applications/MetaTrader 5.app/.../wine64
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
Display: gui
Arch: arch -x86_64
```
## 6. Run First Backtest
## Run Backtest
```
Run a backtest on MyEA from 2025.01.01 to 2025.03.31
```
The AI will:
1. Verify setup
2. Compile your EA
3. Clean MT5 cache
4. Run backtest
5. Extract and analyze results
6. Report key findings
The AI runs: compile → clean → backtest → extract → analyze.
---
**Next:** See [TOOLS.md](TOOLS.md) for all 89 available tools.
**Next:** See [TOOLS.md](docs/MCP_TOOLS.md) for all 89 tools.
+14 -95
View File
@@ -11,14 +11,12 @@ MT5's distributed testing lets you farm optimization work across multiple machin
**Mac (master)**
- MetaTrader 5 installed via CrossOver or Wine
- MT5-Quant configured and working for local backtests
- Port 3000 open in macOS firewall (or whichever port MT5 uses — check below)
- Port 3000 open in macOS firewall (or whichever port MT5 uses)
**Linux server (agents)**
- Wine 7.0+ (or Wine Staging for better compatibility)
- Wine 7.0+
- `metatester64.exe` from an MT5 installation
- Access to the same MT5 data files (tick history) as the master — or let agents download on first run
---
- Access to the same MT5 data files (tick history) as the master
## Step 1: Find the MT5 agent port on Mac
@@ -31,7 +29,6 @@ ls ~/Library/Application\ Support/MetaQuotes/*/drive_c/Program\ Files/MetaTrader
You'll see directories like:
```
Agent-127.0.0.1-3000/ ← local agents (loopback)
Agent-127.0.0.1-3001/
Agent-0.0.0.0-3000/ ← remote listener (if enabled)
```
@@ -40,69 +37,23 @@ If you don't see `Agent-0.0.0.0-*` directories, enable remote agents in MT5:
Note the port number (default: 3000).
---
## Step 2: Open firewall on Mac
```bash
# Check current firewall rules
sudo pfctl -s rules
# Allow incoming on agent port (example: 3000)
# Add to /etc/pf.conf or use macOS Firewall in System Settings
```
Or use macOS System Settings → Privacy & Security → Firewall → Firewall Options → Add MetaTrader 5.
---
## Step 3: Install Wine on Linux server
```bash
# Ubuntu/Debian
sudo dpkg --add-architecture i386
sudo apt update
sudo apt install wine64 wine32
# Verify
wine64 --version
```
---
## Step 4: Copy `metatester64.exe` to Linux
## Step 2: Copy `metatester64.exe` to Linux
From your Mac MT5 installation:
```bash
MAC_MT5="$HOME/Library/Application Support/MetaQuotes/Terminal/XXXXX/drive_c/Program Files/MetaTrader 5"
MAC_MT5="$HOME/Library/Application Support/MetaQuotes/Terminal/XXX/ drive_c/Program Files/MetaTrader 5"
scp "${MAC_MT5}/metatester64.exe" user@linux-server:~/mt5agents/
scp "${MAC_MT5}/metaeditor64.exe" user@linux-server:~/mt5agents/ # not required for agents
```
Also copy the required DLLs (they're in the same directory):
```bash
scp "${MAC_MT5}"/*.dll user@linux-server:~/mt5agents/
```
---
## Step 5: Launch agents on Linux
## Step 3: Launch agents on Linux
```bash
# On the Linux server
cd ~/mt5agents/
# Replace MAC_IP with your Mac's local IP address
# Replace 8 with number of CPU cores - 1
wine64 metatester64.exe /server:192.168.1.100:3000 /agents:8
```
**What this does:**
- Connects to your Mac's MT5 master at `192.168.1.100:3000`
- Registers 8 worker agents
- MT5 on Mac will show 8 new agents in the agent manager
**To run as a background service:**
```bash
nohup wine64 metatester64.exe /server:192.168.1.100:3000 /agents:8 \
@@ -110,9 +61,7 @@ nohup wine64 metatester64.exe /server:192.168.1.100:3000 /agents:8 \
disown
```
---
## Step 6: Verify agents appear in MT5
## Step 4: Verify agents appear in MT5
On your Mac, open MT5:
**View → Strategy Tester → Agents tab**
@@ -120,68 +69,38 @@ On your Mac, open MT5:
You should see entries like:
```
Agent-192.168.1.200-3000 [Active]
Agent-192.168.1.200-3001 [Active]
...
```
If agents appear as `[Inactive]`, check:
1. Mac firewall is allowing incoming connections on the agent port
2. Linux server can reach Mac IP: `ping 192.168.1.100`
3. Port is open: `nc -zv 192.168.1.100 3000`
## Step 5: Configure MT5-Quant for remote agents
---
## Step 7: Configure MT5-Quant for remote agents
In `config/MT5-Quant.yaml`:
In `config/mt5-quant.yaml`:
```yaml
optimization:
remote_agents:
enabled: true
check_agent_count: true # Verify remote agents are connected before launching
min_agents: 4 # Require at least N agents before optimizing
check_agent_count: true
min_agents: 4
```
MT5-Quant will log the agent count before launching optimization:
```
[optimize] Local agents: 9, Remote agents: 8, Total: 17
[optimize] Estimated completion: ~105 minutes (17,640 passes at 17 agents)
```
---
## Tick Data Sync
Remote agents need tick history to replay trades. On first run, MT5 automatically downloads ticks from the broker for each symbol+period combination tested. This can take 10-30 minutes per symbol.
**Speed up first run:** Pre-populate the tick cache on the Linux server by copying from Mac:
On first run, MT5 automatically downloads ticks from the broker. Pre-populate on Linux:
```bash
# On Mac — find tick data location
find ~/Library/Application\ Support/MetaQuotes -name "*.bin" | grep "XAUUSD"
# Typical path:
# Terminal/XXXXX/drive_c/users/user/AppData/Roaming/MetaQuotes/Terminal/Common/Files/
# Copy to Linux
scp -r "${TICK_DIR}" user@linux-server:~/mt5agents/ticks/
```
---
## Troubleshooting
**Agents connect then immediately disconnect**
- MT5 version mismatch between `metatester64.exe` (from Mac) and the master. Use the exact same build number.
- Check `~/mt5agents/agents.log` for Wine errors.
**Agents show as connected but don't receive work**
- MT5 sometimes requires optimization to be started before assigning work to new agents.
- Start an optimization from the MT5 GUI first to "activate" remote agents, then cancel it and use MT5-Quant.
**Performance is slower with remote agents**
- Network latency between Mac and Linux server. Results are sent over TCP after each pass.
- Local gigabit network: negligible. WiFi or WAN: significant overhead. Use wired connection.
**Linux server runs out of memory**
- Each `metatester64.exe` instance uses ~200-400MB.
- 8 agents = ~2-3GB RAM. Size agent count accordingly.
- Use wired connection. WiFi or WAN: significant overhead.
+44 -3
View File
@@ -17,12 +17,17 @@ If using CrossOver, confirm bottle is named `MetaTrader5`.
### Linux
Install Wine 7.0+ and confirm on PATH:
```bash
sudo apt install wine64 # Debian/Ubuntu
sudo dnf install wine # Fedora/RHEL
which wine64 # confirm on PATH
# Debian/Ubuntu
sudo apt install wine64
# Fedora/RHEL
sudo dnf install wine
which wine64
```
Ask your LLM platform to install Wine if it's missing.
## terminal64.exe Missing
MT5 unpacks `terminal64.exe` only after first launch.
@@ -69,6 +74,42 @@ Set `MT5_MCP_HOME` or ensure config exists at:
4. **Date range has no trades** — try wider range or confirm symbol was active.
## Backtest Completes But Always Shows "journal-only" (No HTML Report)
**Symptom:** Every backtest produces `status: completed_no_html` and all deal profits are `0.0`.
**Cause:** On Wine/macOS, `ShutdownTerminal=1` does **not** cause `terminal64.exe` to exit after the
test completes. The tester agent (MetaTester 5) closes normally, but the terminal process stays alive
indefinitely. Without process exit, MT5 never writes the HTML report.
**How the pipeline handles this:** The inactivity watchdog detects that the tester log has stopped
growing (test done), waits 30 seconds polling for the HTML file, then kills `terminal64.exe`
unconditionally. If the HTML appears during the wait it is extracted; otherwise journal extraction
provides deal counts and final balance (but no per-deal P&L).
**To maximise HTML report chances:** Pass `inactivity_kill_secs=120` explicitly (it is disabled by
default). With `shutdown=true` (default) this gives MT5 120s of quiet time + 30s of HTML-wait before
the kill.
```
launch_backtest(expert: "MyEA", shutdown: true, inactivity_kill_secs: 120)
```
**Fallback data available from journal:**
- Deal count, volume, prices, timestamps ✓
- Final balance (pips) ✓
- Per-deal profit/loss ✗ (always 0.0)
- Win rate, profit factor, Sharpe, drawdown ✗ (require HTML)
## `list_deals` Returns 0 Filtered Results
Analytics tools filter deals with `is_closed_trade()` which checks `entry = "out"`. If all deals
show `entry = "in"`, the position tracker that infers direction from signed lot accumulation may
not have run (old binary in memory).
**Fix:** Restart the MCP server (restart Claude Code / your IDE) after installing a new binary.
The server process caches the binary in memory — `install` doesn't hot-reload it.
## MetaEditor Compile Errors
Check `<terminal_dir>/MQL5/Logs/`:
-147
View File
@@ -1,147 +0,0 @@
# VS Code MCP Integration Setup
## Quick Setup
### Option 1: Download Prebuilt Binary (Recommended)
```bash
# macOS (Apple Silicon)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
tar -xzf mt5.tar.gz
# Linux (x64)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
tar -xzf mt5.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: `/path/to/mt5-quant/mcp-server/bin/mt5-quant`
### Method 2: Edit mcp.json Directly
Add to `.vscode/mcp.json` in your workspace:
```json
{
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
```
Create the file:
```bash
mkdir -p .vscode
cat > .vscode/mcp.json << 'EOF'
{
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
EOF
```
### Method 3: VS Code CLI
```bash
code --add-mcp '{"name":"mt5-quant","command":"/path/to/mt5-quant/mcp-server/bin/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/mcp-server/bin/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)
-108
View File
@@ -1,108 +0,0 @@
# Windsurf MCP Integration Setup
## Quick Setup
### Option 1: Download Prebuilt Binary (Recommended)
```bash
# macOS (Apple Silicon)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
tar -xzf mt5.tar.gz
# Linux (x64)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
tar -xzf mt5.tar.gz
```
### Option 2: Build from Source
```bash
bash scripts/build-rust.sh
```
### 2. Configure Windsurf
Edit `~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
```
Or use the automated setup:
```bash
# Install binary to standard location
cp mcp-server/bin/mt5-quant ~/.local/bin/
# Then configure
bash scripts/setup.sh
```
### 3. Restart Windsurf
Close and reopen Windsurf to load the MCP server.
### 4. Verify
In Windsurf chat, test with:
```
Run verify_setup
```
## Deployment to Multiple Machines
### Build for Distribution
```bash
# Build release binary
cargo build --release
# Create tarball
tar -czf mt5-quant-macos-arm64.tar.gz -C target/release mt5-quant
# Deploy to remote server
scp mt5-quant-macos-arm64.tar.gz user@server:~/
ssh user@server "tar -xzf mt5-quant-macos-arm64.tar.gz -C /opt/"
ssh user@server "ln -s /opt/mt5-quant /usr/local/bin/"
# Copy config
scp -r config/mt5-quant.yaml user@server:~/.config/mt5-quant/config/
```
### Target Machine Requirements
- MetaTrader 5 installed (via Wine/CrossOver)
- Config file at `~/.config/mt5-quant/config/mt5-quant.yaml`
- **NO Python required!**
### Windsurf Config on Target Machine
```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
1. Check Windsurf logs: `~/.windsurf/logs/`
2. Verify executable path is absolute
3. Test executable manually: `./mcp-server/bin/mt5-quant --help`
### Config not found
Set `MT5_MCP_HOME` environment variable or ensure config is at default location:
- macOS: `~/.config/mt5-quant/config/mt5-quant.yaml`
### Permission denied
```bash
chmod +x /path/to/mt5-quant
```
+4 -4
View File
@@ -7,13 +7,13 @@
"url": "https://github.com/masdevid/mt5-quant",
"source": "github"
},
"version": "1.32.1",
"version": "1.33.0",
"packages": [
{
"registryType": "mcpb",
"version": "1.32.1",
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.32.1/mcp-mt5-quant-macos-arm64.tar.gz",
"fileSha256": "a2525e1c0e74587d87f37414652b29b402983625663eb2b3435496e31a2e9714",
"version": "1.33.0",
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.33.0/mcp-mt5-quant-macos-arm64.tar.gz",
"fileSha256": "54c6fd9d1f0d009bad1fa001582607377442ecfca908e3692b09d4c6b2732074",
"transport": {
"type": "stdio"
},
+82 -45
View File
@@ -77,59 +77,96 @@ impl ReportExtractor {
fn parse_deals_html(&self, text: &str) -> Result<Vec<Deal>> {
let mut deals = Vec::new();
let re = regex::Regex::new(r"<tr[^>]*>.*?Deal.*?Time.*?Type.*?Direction.*?</tr>(.*)")
.map_err(|e| anyhow!("Regex error: {}", e))?;
// (?s) = dotall: makes '.' match '\n' so the regex works on multiline HTML.
// MT5 HTML column order: Time | Deal | Symbol | Type | Direction |
// Volume | Price | Order | Commission | Swap | Profit | Balance | Comment
//
// Strategy: locate the deals table by finding the header <tr> that contains
// the column names, then parse every subsequent <tr> as a data row.
// We try two header patterns to handle different MT5 versions/locales.
if let Some(captures) = re.captures(text) {
let section = captures.get(1).map(|m| m.as_str()).unwrap_or("");
let row_re = regex::Regex::new(r"(?s)<tr[^>]*>(.*?)</tr>")
.map_err(|e| anyhow!("Row regex error: {}", e))?;
let cell_re = regex::Regex::new(r"(?s)<td[^>]*>(.*?)</td>")
.map_err(|e| anyhow!("Cell regex error: {}", e))?;
let row_re = regex::Regex::new(r"<tr[^>]*>(.*?)</tr>")
.map_err(|e| anyhow!("Regex error: {}", e))?;
// Collect all <tr> blocks once, then find the deals header and parse from there.
let rows: Vec<&str> = row_re.captures_iter(text)
.filter_map(|cap| cap.get(0).map(|m| m.as_str()))
.collect();
for row_caps in row_re.captures_iter(section) {
let row = row_caps.get(1).map(|m| m.as_str()).unwrap_or("");
let cell_re = regex::Regex::new(r"<td[^>]*>(.*?)</td>")
.map_err(|e| anyhow!("Regex error: {}", e))?;
let cells: Vec<String> = cell_re.captures_iter(row)
.filter_map(|cap| cap.get(1))
.map(|m| Self::strip_tags(m.as_str()))
.map(|s| s.replace(',', ""))
.collect();
// Find the header row index: it must contain both "Deal" and "Symbol" (case-insensitive).
let header_idx = rows.iter().position(|row| {
let lower = row.to_lowercase();
(lower.contains(">deal<") || lower.contains(">deal </")) &&
(lower.contains(">symbol<") || lower.contains(">symbol </"))
});
if cells.len() < 3 || cells[0].is_empty() {
continue;
let start_idx = match header_idx {
Some(i) => i + 1,
None => {
// Fallback: look for any row containing Time+Volume+Profit headers
let alt = rows.iter().position(|row| {
let lower = row.to_lowercase();
lower.contains(">time<") && lower.contains(">volume<") && lower.contains(">profit<")
});
match alt {
Some(i) => i + 1,
None => {
tracing::warn!("parse_deals_html: no deals table header found");
return Ok(deals);
}
}
if cells.iter().take(5).any(|c| {
let c_lower = c.trim().to_lowercase();
c_lower == "balance" || c_lower == "credit"
}) {
continue;
}
let deal = Deal {
time: cells.get(0).cloned().unwrap_or_default(),
deal: cells.get(1).cloned().unwrap_or_default(),
symbol: cells.get(2).cloned().unwrap_or_default(),
deal_type: cells.get(3).cloned().unwrap_or_default(),
entry: cells.get(4).cloned().unwrap_or_default(),
volume: cells.get(5).and_then(|s| s.parse().ok()).unwrap_or(0.0),
price: cells.get(6).and_then(|s| s.parse().ok()).unwrap_or(0.0),
order: cells.get(7).cloned().unwrap_or_default(),
commission: cells.get(8).and_then(|s| s.parse().ok()).unwrap_or(0.0),
swap: cells.get(9).and_then(|s| s.parse().ok()).unwrap_or(0.0),
profit: cells.get(10).and_then(|s| s.parse().ok()).unwrap_or(0.0),
balance: cells.get(11).and_then(|s| s.parse().ok()).unwrap_or(0.0),
comment: cells.get(12).cloned().unwrap_or_default(),
magic: cells.get(13).cloned(),
};
deals.push(deal);
}
};
for row in &rows[start_idx..] {
let cells: Vec<String> = cell_re.captures_iter(row)
.filter_map(|cap| cap.get(1))
.map(|m| Self::strip_tags(m.as_str()))
.map(|s| s.replace(',', ""))
.collect();
if cells.len() < 3 || cells[0].trim().is_empty() {
continue;
}
// Skip balance/credit operation rows (Type column is index 3 in MT5 HTML)
let type_cell = cells.get(3).map(|s| s.trim().to_lowercase()).unwrap_or_default();
if type_cell == "balance" || type_cell == "credit" {
continue;
}
// Also skip sub-header rows that repeat column names
if type_cell == "type" || cells.get(1).map(|s| s.trim().to_lowercase()).as_deref() == Some("deal") {
continue;
}
// Skip rows with no deal number (e.g. totals row)
let deal_num = cells.get(1).map(|s| s.trim().to_string()).unwrap_or_default();
if deal_num.is_empty() {
continue;
}
let deal = Deal {
time: cells.get(0).cloned().unwrap_or_default(),
deal: deal_num,
symbol: cells.get(2).cloned().unwrap_or_default(),
deal_type: cells.get(3).cloned().unwrap_or_default(),
entry: cells.get(4).cloned().unwrap_or_default(),
volume: cells.get(5).and_then(|s| s.parse().ok()).unwrap_or(0.0),
price: cells.get(6).and_then(|s| s.parse().ok()).unwrap_or(0.0),
order: cells.get(7).cloned().unwrap_or_default(),
commission: cells.get(8).and_then(|s| s.parse().ok()).unwrap_or(0.0),
swap: cells.get(9).and_then(|s| s.parse().ok()).unwrap_or(0.0),
profit: cells.get(10).and_then(|s| s.parse().ok()).unwrap_or(0.0),
balance: cells.get(11).and_then(|s| s.parse().ok()).unwrap_or(0.0),
comment: cells.get(12).cloned().unwrap_or_default(),
magic: cells.get(13).cloned(),
};
deals.push(deal);
}
tracing::info!("parse_deals_html: extracted {} deals", deals.len());
Ok(deals)
}
+30 -4
View File
@@ -90,8 +90,8 @@ impl MqlCompiler {
.map(|s| s.to_string())
.collect();
let ex5_path = staged_mq5.with_extension("ex5");
if !ex5_path.exists() {
let staged_ex5 = staged_mq5.with_extension("ex5");
if !staged_ex5.exists() {
let final_errors = if errors.is_empty() {
vec![format!("Compilation failed. Log:\n{}", &log_text[log_text.len().saturating_sub(500)..])]
} else {
@@ -107,11 +107,37 @@ impl MqlCompiler {
});
}
let binary_size = fs::metadata(&ex5_path)?.len();
let binary_size = fs::metadata(&staged_ex5)?.len();
// Deploy compiled output to the real MQL5/Experts/{ea_name}/ directory so
// MT5 can actually load it. The temp dir is only needed to avoid Wine path
// issues with spaces; the real experts dir is the authoritative location.
let final_ex5_path = if let Some(experts_dir) = self.config.experts_dir.as_ref() {
let real_experts = PathBuf::from(experts_dir);
let real_ea_dir = real_experts.join(ea_name);
fs::create_dir_all(&real_ea_dir)?;
// Sync source files (.mq5 + .mqh) into the real experts dir so that
// future compiles from the experts dir also work correctly.
if let Err(e) = self.sync_project_to_experts(source_path, &real_experts) {
tracing::warn!("Could not sync source to experts dir: {}", e);
}
// Copy the compiled .ex5 to the real experts dir.
let dest_ex5 = real_ea_dir.join(format!("{}.ex5", ea_name));
fs::copy(&staged_ex5, &dest_ex5)?;
tracing::info!("Deployed {} → {}", staged_ex5.display(), dest_ex5.display());
dest_ex5
} else {
// No experts_dir configured — fall back to the staged path so callers
// still get a valid path even if MT5 won't find it automatically.
tracing::warn!("experts_dir not configured; .ex5 left in staging dir");
staged_ex5
};
Ok(CompileResult {
success: errors.is_empty(),
ex5_path: Some(ex5_path),
ex5_path: Some(final_ex5_path),
errors,
warnings,
binary_size,
+1
View File
@@ -148,6 +148,7 @@ async fn run_test_launch(ea: Option<String>, startup_delay: Option<u64>) -> Resu
timeout: 900,
gui: false,
startup_delay_secs: delay,
inactivity_kill_secs: None,
};
let pipeline = BacktestPipeline::new(config);
+96 -35
View File
@@ -247,19 +247,21 @@ impl Config {
fn find_wine(home: &Path) -> Option<String> {
let candidates: &[PathBuf] = &[
// macOS: bundled with the official MT5 app
// macOS: bundled with the official MT5 app (binary is just named 'wine' on recent builds)
PathBuf::from("/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine"),
PathBuf::from("/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"),
// macOS: CrossOver
// macOS: CrossOver (new versions may use 'wine', older ones 'wine64')
home.join("Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine"),
home.join("Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine64"),
// macOS: Homebrew (Apple Silicon)
PathBuf::from("/opt/homebrew/bin/wine64"),
// macOS: Homebrew Apple Silicon (prefer 'wine', fall back to 'wine64')
PathBuf::from("/opt/homebrew/bin/wine"),
// macOS: Homebrew (Intel)
PathBuf::from("/usr/local/bin/wine64"),
PathBuf::from("/opt/homebrew/bin/wine64"),
// macOS: Homebrew Intel
PathBuf::from("/usr/local/bin/wine"),
PathBuf::from("/usr/local/bin/wine64"),
// Linux
PathBuf::from("/usr/bin/wine64"),
PathBuf::from("/usr/bin/wine"),
PathBuf::from("/usr/bin/wine64"),
];
candidates.iter()
.find(|p| p.exists())
@@ -508,29 +510,50 @@ impl Config {
self.terminal_dir.as_ref().map(|d| Path::new(d).to_path_buf())
}
/// Scan Bases/*/history/ for symbol directories that contain at least one .hcc file.
/// Returns deduplicated, sorted list of symbol names available for backtesting.
/// If server_filter is provided, only scans that specific server's directory.
/// Scan the tester's own history store for symbols with downloaded data.
///
/// MT5 maintains two separate history trees:
/// • `Bases/{server}/history/` — live-trading tick/bar data (NOT usable by tester)
/// • `Tester/bases/{server}/history/` — data the Strategy Tester actually reads
///
/// Scanning `Bases/` (the old approach) returned symbols that exist for live trading
/// but may have no tester data, causing the tester to fail with "symbol does not exist".
/// This function scans `Tester/bases/` instead, which is the authoritative source.
///
/// Falls back to `Bases/` only when `Tester/bases/` is absent (first-run / no backtests yet).
///
/// If `server_filter` is provided only that server's directory is scanned.
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(),
};
let bases_dir = mt5_dir.join("Bases");
if !bases_dir.is_dir() {
return Vec::new();
}
// Prefer the tester's own data store; fall back to live-trading Bases/ when absent.
let tester_bases = mt5_dir.join("Tester").join("bases");
let bases_dir = if tester_bases.is_dir() {
tester_bases
} else {
let fallback = mt5_dir.join("Bases");
if !fallback.is_dir() {
return Vec::new();
}
tracing::warn!(
"Tester/bases/ not found — falling back to Bases/ for symbol discovery. \
Run at least one backtest to populate tester data."
);
fallback
};
let mut symbols = std::collections::HashSet::new();
// Bases/{server}/history/{symbol}/{year}.hcc
// {bases_dir}/{server}/history/{symbol}/ — directory presence = data available
// (the tester uses .hst/.hcc files; existence of the directory is sufficient)
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;
}
@@ -539,7 +562,7 @@ impl Config {
continue;
}
}
let history_dir = server.path().join("history");
if !history_dir.is_dir() {
continue;
@@ -550,23 +573,8 @@ impl Config {
if !sym_path.is_dir() {
continue;
}
// Only include if at least one .hcc file exists (has downloaded data)
let has_data = fs::read_dir(&sym_path)
.ok()
.map(|entries| {
entries.filter_map(|e| e.ok()).any(|e| {
e.path().extension()
.and_then(|x| x.to_str())
.map(|x| x == "hcc")
.unwrap_or(false)
})
})
.unwrap_or(false);
if has_data {
if let Some(name) = sym_path.file_name().and_then(|n| n.to_str()) {
symbols.insert(name.to_string());
}
if let Some(name) = sym_path.file_name().and_then(|n| n.to_str()) {
symbols.insert(name.to_string());
}
}
}
@@ -578,6 +586,59 @@ impl Config {
sorted
}
/// Find the closest available tester symbol to the one requested.
///
/// Matching priority (first hit wins):
/// 1. Exact match → `XAUUSD.cent` == `XAUUSD.cent`
/// 2. Case-insensitive exact match → `xauusd.cent` → `XAUUSD.cent`
/// 3. Strip/add common cent suffixes → `XAUUSDc` ↔ `XAUUSD.cent`
/// 4. Prefix match on the base ticker → `XAUUSD` matches `XAUUSD.cent`
pub fn resolve_symbol<'a>(requested: &str, available: &'a [String]) -> Option<&'a str> {
if available.is_empty() {
return None;
}
// 1. Exact
if let Some(s) = available.iter().find(|s| s.as_str() == requested) {
return Some(s.as_str());
}
// 2. Case-insensitive exact
let req_lower = requested.to_lowercase();
if let Some(s) = available.iter().find(|s| s.to_lowercase() == req_lower) {
return Some(s.as_str());
}
// 3. Cent-suffix normalisation: build a normalised "base" for both sides
// Strip known cent suffixes: `.cent`, `c` (trailing, uppercase only), `.c`
fn base_ticker(sym: &str) -> &str {
let s = sym.trim_end_matches(".cent")
.trim_end_matches(".c");
// Strip trailing lowercase 'c' only when the rest is all-uppercase
// (so "XAUUSDc" → "XAUUSD", but "Misc" stays "Misc")
if s.ends_with('c') && s[..s.len()-1].chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) {
&s[..s.len()-1]
} else {
s
}
}
let req_base = base_ticker(requested).to_lowercase();
if let Some(s) = available.iter().find(|s| base_ticker(s).to_lowercase() == req_base) {
return Some(s.as_str());
}
// 4. Prefix match: available symbol starts with the requested string (or vice-versa)
if let Some(s) = available.iter().find(|s| {
let sl = s.to_lowercase();
sl.starts_with(&req_lower) || req_lower.starts_with(sl.as_str())
}) {
return Some(s.as_str());
}
None
}
/// 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))
+4
View File
@@ -79,6 +79,9 @@ pub struct BacktestJob {
pub mt5_pid: Option<u32>,
pub expected_report_path: String,
pub timeout_seconds: u64,
/// Set by background monitor: "running"|"completed"|"completed_no_html"|"failed"|"timeout"
#[serde(default)]
pub status: Option<String>,
}
impl BacktestJob {
@@ -101,6 +104,7 @@ impl BacktestJob {
mt5_pid: None,
expected_report_path,
timeout_seconds,
status: Some("running".to_string()),
}
}
}
+257 -75
View File
@@ -6,6 +6,27 @@ use std::process::{Command, Stdio};
use crate::models::Config;
/// Read a file that may be UTF-16LE (with BOM) or UTF-8, returning a UTF-8 String.
/// MT5 .set and .ini files are typically UTF-16LE with BOM (0xFF 0xFE).
fn read_file_as_utf8(path: &Path) -> Result<String> {
let bytes = fs::read(path)?;
// Check for UTF-16LE BOM (0xFF 0xFE)
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
// UTF-16LE with BOM - skip the 2-byte BOM and decode
let utf16_data: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
String::from_utf16(&utf16_data)
.map_err(|e| anyhow!("Failed to decode UTF-16LE: {}", e))
} else {
// Try UTF-8
String::from_utf8(bytes)
.map_err(|e| anyhow!("Failed to decode as UTF-8: {}", e))
}
}
pub struct OptimizationParams {
pub expert: String,
pub set_file: String,
@@ -13,7 +34,6 @@ pub struct OptimizationParams {
pub from_date: String,
pub to_date: String,
pub deposit: u32,
pub model: u8,
pub leverage: u32,
pub currency: String,
}
@@ -27,7 +47,6 @@ impl Default for OptimizationParams {
from_date: String::new(),
to_date: String::new(),
deposit: 10000,
model: 0,
leverage: 500,
currency: "USD".to_string(),
}
@@ -78,7 +97,8 @@ impl OptimizationRunner {
let log_file = PathBuf::from(format!("/tmp/mt5opt_{}.log", timestamp));
// Count combinations
let combinations = self.count_combinations(&params.set_file)?;
let combinations = self.count_combinations(&params.set_file)
.map_err(|e| anyhow!("count_combinations failed: {}", e))?;
// Get paths
let mt5_dir = self.config.terminal_dir.as_ref()
@@ -89,50 +109,167 @@ impl OptimizationRunner {
// Write .set file as UTF-16LE with BOM directly to MT5 tester directory
let wine_prefix_dir = self.get_wine_prefix_dir(mt5_dir)?;
let tester_dir = wine_prefix_dir.join("drive_c/Program Files/MetaTrader 5/MQL5/Profiles/Tester");
fs::create_dir_all(&tester_dir)?;
fs::create_dir_all(&tester_dir).map_err(|e| anyhow!("create_dir_all({}) failed: {}", tester_dir.display(), e))?;
let dst_set_file = tester_dir.join(format!("{}.set", params.expert));
self.write_utf16le_set(&params.set_file, &dst_set_file)?;
self.write_utf16le_set(&params.set_file, &dst_set_file)
.map_err(|e| anyhow!("write_utf16le_set({}) failed: {}", dst_set_file.display(), e))?;
// Reset OptMode in terminal.ini
self.reset_optmode(mt5_dir)?;
// Patch terminal.ini [Tester] section with optimization params (primary mechanism)
let terminal_ini = if Path::new(mt5_dir).join("config").exists() {
Path::new(mt5_dir).join("config").join("terminal.ini")
} else {
Path::new(mt5_dir).join("terminal.ini")
};
let mt5_ini_text = if terminal_ini.exists() {
read_file_as_utf8(&terminal_ini).unwrap_or_default()
} else {
String::new()
};
let expert_path = if let Some(experts_dir) = &self.config.experts_dir {
let nested = Path::new(experts_dir).join(&params.expert).join(format!("{}.mq5", params.expert));
if nested.exists() {
format!("Experts\\{}\\{}.ex5", params.expert, params.expert)
} else {
format!("Experts\\{}.ex5", params.expert)
}
} else {
format!("Experts\\{}.ex5", params.expert)
};
let tester_section = format!(
"[Tester]\n\
Expert={}\n\
ExpertParameters={}.set\n\
Symbol={}\n\
Period=M1\n\
Model=4\n\
FromDate={}\n\
ToDate={}\n\
ForwardMode=0\n\
Deposit={}\n\
Currency={}\n\
ProfitInPips=0\n\
Leverage={}\n\
Execution=10\n\
Optimization=2\n\
Agents=10\n\
Visual=0\n\
Report=reports\\opt_report.htm\n\
ReplaceReport=1\n\
ShutdownTerminal=1",
expert_path, params.expert, params.symbol,
params.from_date, params.to_date, params.deposit, params.currency, params.leverage,
);
let agents_section = "\
[Agents]\n\
Agent0000=11111111-1111-1111-1111-111111111111\n\
AgentStatus0000=3\n\
AgentState0000=0\n\
Enabled0000=1\n\
IP0000=127.0.0.1\n\
Port0000=3000\n\
Agent0001=22222222-2222-2222-2222-222222222222\n\
AgentStatus0001=3\n\
AgentState0001=0\n\
Enabled0001=1\n\
IP0001=127.0.0.1\n\
Port0001=3001\n\
Agent0002=33333333-3333-3333-3333-333333333333\n\
AgentStatus0002=3\n\
AgentState0002=0\n\
Enabled0002=1\n\
IP0002=127.0.0.1\n\
Port0002=3002\n\
Agent0003=44444444-4444-4444-4444-444444444444\n\
AgentStatus0003=3\n\
AgentState0003=0\n\
Enabled0003=1\n\
IP0003=127.0.0.1\n\
Port0003=3003";
let updated_ini = Self::patch_ini_section(&mt5_ini_text, "Tester", &tester_section);
// Strip any stale [Agents] sections from previous runs, then append fresh one
let cleaned = Self::strip_ini_section(&updated_ini, "Agents");
let final_ini = format!("{}\n{}", cleaned.trim_end(), agents_section);
let mut utf16_out: Vec<u8> = vec![0xFF, 0xFE];
utf16_out.extend(final_ini.encode_utf16().flat_map(|c| c.to_le_bytes()));
fs::write(&terminal_ini, utf16_out)?;
// Get Wine prefix directory
let wine_prefix_dir = self.get_wine_prefix_dir(mt5_dir)?;
// Write /config: INI to trigger tester/optimizer mode
// For /config: format, Expert path is relative to MQL5/Experts/ (no Experts\ prefix)
let opt_config_win = r"C:\mt5opt_config.ini";
let opt_config_host = wine_prefix_dir.join("drive_c").join("mt5opt_config.ini");
let mut opt_ini = String::new();
if let Some(login) = &self.config.backtest_login {
if let Some(server) = &self.config.backtest_server {
opt_ini.push_str("[Common]\n");
opt_ini.push_str(&format!("Login={}\n", login));
opt_ini.push_str(&format!("Server={}\n", server));
if let Some(password) = &self.config.backtest_password {
opt_ini.push_str(&format!("Password={}\n", password));
}
opt_ini.push_str("\n");
}
}
opt_ini.push_str("[Tester]\n");
opt_ini.push_str(&format!("Expert={}.ex5\n", params.expert));
opt_ini.push_str(&format!("ExpertParameters={}.set\n", params.expert));
opt_ini.push_str(&format!("Symbol={}\n", params.symbol));
opt_ini.push_str("Period=M1\n");
opt_ini.push_str("Optimization=2\n");
opt_ini.push_str(&format!("FromDate={}\n", params.from_date));
opt_ini.push_str(&format!("ToDate={}\n", params.to_date));
opt_ini.push_str("ForwardMode=0\n");
opt_ini.push_str(&format!("Deposit={}\n", params.deposit));
opt_ini.push_str(&format!("Currency={}\n", params.currency));
opt_ini.push_str("ProfitInPips=0\n");
opt_ini.push_str(&format!("Leverage={}\n", params.leverage));
opt_ini.push_str("Execution=10\n");
opt_ini.push_str("Visual=0\n");
opt_ini.push_str("Report=reports\\opt_report.htm\n");
opt_ini.push_str("ReplaceReport=1\n");
opt_ini.push_str("ShutdownTerminal=1\n");
fs::write(&opt_config_host, opt_ini.as_bytes())?;
// Build optimization INI
let ini_path = wine_prefix_dir.join("drive_c/mt5mcp_backtest.ini");
let ini_content = format!(r#"[Tester]
Expert={}
Symbol={}
Period=M5
Deposit={}
Currency={}
Leverage={}
Model={}
FromDate={}
ToDate={}
Report=C:\mt5mcp_opt_report
Optimization=2
ExpertParameters={}.set
ShutdownTerminal=1
"#, params.expert, params.symbol, params.deposit, params.currency,
params.leverage, params.model, params.from_date, params.to_date, params.expert);
fs::write(&ini_path, ini_content)?;
// Build launch script (macOS-compatible with /config: to trigger tester mode)
let wine_bin = Path::new(wine_exe);
let wine_root = wine_bin
.parent()
.and_then(|p| p.parent())
.ok_or_else(|| anyhow!("Cannot derive Wine root from wine_exe"))?;
let ext_libs = wine_root.join("lib").join("external");
let wine_libs = wine_root.join("lib");
let dyld = format!("{}:{}:/usr/lib:/usr/local/lib",
ext_libs.display(), wine_libs.display());
let terminal_host = wine_prefix_dir.join("drive_c")
.join("Program Files").join("MetaTrader 5").join("terminal64.exe");
// Build batch file
let batch_path = wine_prefix_dir.join("drive_c/mt5mcp_run.bat");
let batch_content = format!(r#"@echo off
"C:\Program Files\MetaTrader 5\terminal64.exe" /config:C:\mt5mcp_backtest.ini
"#);
fs::write(&batch_path, batch_content)?;
let script = format!(
"#!/bin/sh\n\
export DYLD_FALLBACK_LIBRARY_PATH='{dyld}'\n\
export WINEPREFIX='{prefix}'\n\
export WINEDEBUG='-all'\n\
nohup '{wine}' '{terminal}' '/config:{config}' >/dev/null 2>&1 &\n",
dyld = dyld,
prefix = wine_prefix_dir.display(),
wine = wine_exe,
terminal = terminal_host.display(),
config = opt_config_win,
);
// Launch detached process
let cmd = format!("cmd.exe /c 'C:\\mt5mcp_run.bat'");
let child = Command::new(wine_exe)
.arg(&cmd)
let script_path = std::env::temp_dir().join("mt5opt_launch.sh");
fs::write(&script_path, &script)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
}
let child = Command::new("/bin/sh")
.arg(&script_path)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
.spawn()
.map_err(|e| anyhow!("spawn /bin/sh {} failed: {}", script_path.display(), e))?;
let pid = child.id();
@@ -150,7 +287,7 @@ ShutdownTerminal=1
}
fn count_combinations(&self, set_file: &str) -> Result<u64> {
let content = fs::read_to_string(set_file)?;
let content = read_file_as_utf8(Path::new(set_file))?;
let mut total: u64 = 1;
for line in content.lines() {
@@ -179,13 +316,23 @@ ShutdownTerminal=1
}
fn write_utf16le_set(&self, src: &str, dst: &Path) -> Result<()> {
let content = fs::read_to_string(src)?;
let content = read_file_as_utf8(Path::new(src))?;
// Create parent directory if needed
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
// Remove existing file if read-only from previous run
if dst.exists() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(dst, fs::Permissions::from_mode(0o644));
}
let _ = fs::remove_file(dst);
}
// Write UTF-16LE with BOM
let mut utf16_content: Vec<u16> = vec![0xFEFF]; // BOM
utf16_content.extend(content.encode_utf16());
@@ -195,50 +342,18 @@ ShutdownTerminal=1
.collect();
fs::write(dst, bytes)?;
// Make read-only
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dst, fs::Permissions::from_mode(0o444))?;
}
Ok(())
}
fn reset_optmode(&self, mt5_dir: &str) -> Result<()> {
let terminal_ini = Path::new(mt5_dir).join("terminal.ini");
if !terminal_ini.exists() {
return Ok(());
}
let content = fs::read_to_string(&terminal_ini)?;
let updated = content
.lines()
.map(|line| {
if line.starts_with("OptMode=") {
"OptMode=0".to_string()
} else if line.starts_with("LastOptimization=") {
String::new()
} else {
line.to_string()
}
})
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("\n");
fs::write(&terminal_ini, updated)?;
Ok(())
}
fn get_wine_prefix_dir(&self, mt5_dir: &str) -> Result<PathBuf> {
let path = Path::new(mt5_dir);
// Go up two levels: .../drive_c/Program Files/MetaTrader 5 -> .../drive_c
// Go up three levels: .../drive_c/Program Files/MetaTrader 5 -> .../net.metaquotes.wine.metatrader5
// (same as backtest pipeline)
let prefix_dir = path
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.ok_or_else(|| anyhow!("Cannot determine Wine prefix from terminal_dir"))?;
Ok(prefix_dir.to_path_buf())
}
@@ -276,6 +391,73 @@ ShutdownTerminal=1
Ok(())
}
/// Replace a [section] in an INI string — removes old content and inserts new.
fn patch_ini_section(text: &str, section: &str, new_content: &str) -> String {
let section_header = format!("[{}]", section);
let mut result = String::new();
let mut in_section = false;
let mut section_found = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed == section_header {
in_section = true;
section_found = true;
continue;
}
if in_section {
if trimmed.starts_with('[') {
in_section = false;
result.push_str(new_content);
if !new_content.ends_with('\n') {
result.push('\n');
}
result.push_str(line);
result.push('\n');
continue;
}
continue;
}
result.push_str(line);
result.push('\n');
}
if !section_found {
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
result.push_str(new_content);
result.push('\n');
} else if in_section {
result.push_str(new_content);
result.push('\n');
}
result
}
/// Remove all lines belonging to a [section] from the INI text.
fn strip_ini_section(text: &str, section: &str) -> String {
let header = format!("[{}]", section);
let mut result = String::new();
let mut skipping = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed == header {
skipping = true;
continue;
}
if skipping && trimmed.starts_with('[') {
skipping = false;
}
if !skipping {
result.push_str(line);
result.push('\n');
}
}
result
}
pub fn get_job_status(&self, job_id: &str) -> Result<serde_json::Value> {
let jobs_dir = Path::new(".mt5mcp_jobs");
let meta_path = jobs_dir.join(format!("{}.json", job_id));
+488 -55
View File
@@ -44,6 +44,9 @@ pub struct BacktestParams {
pub timeout: u64,
pub gui: bool,
pub startup_delay_secs: u64,
/// Kill MT5 if tester agent log hasn't grown for this many seconds.
/// 0 = disabled. Useful to abort EAs that stop trading mid-backtest.
pub inactivity_kill_secs: Option<u64>,
}
pub struct PipelineResult {
@@ -193,7 +196,7 @@ impl BacktestPipeline {
}
self.log_progress(&progress_log, "BACKTEST").await;
// Get MT5 paths
let mt5_dir = self.config.mt5_dir()
.ok_or_else(|| anyhow!("MT5 directory not configured"))?;
@@ -208,15 +211,16 @@ impl BacktestPipeline {
let reports_dir = mt5_dir.join("reports");
fs::create_dir_all(&reports_dir)?;
// Write params via /config: (triggers tester) and terminal.ini (redundancy).
// Kill first — MT5 writes terminal.ini on exit, which would clobber
// the backtest params we're about to write.
self.kill_mt5().await?;
// Write params *after* MT5 is dead so nothing can overwrite them.
let ini_content = self.build_backtest_ini(&params, &report_id)?;
let config_host = wine_prefix.join("drive_c").join("backtest_config.ini");
fs::write(&config_host, ini_content.as_bytes())?;
self.update_terminal_ini(&params, &report_id)?;
// Kill any running MT5
self.kill_mt5().await?;
// Launch MT5 (fire and forget)
let mut cmd = self.build_wine_launch(wine_exe, &wine_prefix)?;
let child = cmd.stdin(std::process::Stdio::null())
@@ -278,6 +282,7 @@ impl BacktestPipeline {
timeout: params.timeout,
gui: params.gui,
startup_delay_secs: params.startup_delay_secs,
inactivity_kill_secs: params.inactivity_kill_secs,
};
tokio::spawn(async move {
Self::monitor_backtest_completion(
@@ -376,12 +381,29 @@ impl BacktestPipeline {
let grace_period = Duration::from_secs(30);
let poll_start = std::time::SystemTime::now();
// Inactivity watchdog: kill MT5 if tester agent log hasn't grown for this long.
// Catches EAs that stall (no ticks processed) or flat periods with zero trades.
let inactivity_threshold = Duration::from_secs(
params.inactivity_kill_secs.unwrap_or(0)
);
let mut last_log_size: u64 = 0;
let mut last_log_activity = tokio::time::Instant::now();
let inactivity_enabled = inactivity_threshold.as_secs() > 0;
loop {
let _elapsed = start.elapsed().as_secs();
// Check for report file
for ext in &[".htm", ".htm.xml", ".html"] {
let candidate = expected_report.with_extension(ext.trim_start_matches('.'));
// Check for report file (exact name first)
for ext in &["htm", "htm.xml", "html"] {
let candidate = if *ext == "htm" {
expected_report.clone()
} else {
// Build alternate extension path without touching expected_report extension
let stem = expected_report.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
expected_report.with_file_name(format!("{}.{}", stem, ext))
};
if candidate.exists() {
tracing::info!("Backtest {} completed: report found at {}", report_id, candidate.display());
let extracted = Self::extract_and_store(&candidate, &report_dir, &report_id, &config, &params).await;
@@ -390,6 +412,15 @@ impl BacktestPipeline {
} else {
tracing::warn!("Backtest {}: extraction failed, keeping report file at {}", report_id, candidate.display());
}
// When ShutdownTerminal=0 (shutdown=false), MT5 stays running after the
// test so the report can be written reliably. Kill it ourselves now that
// extraction is done so we don't leave zombie Wine processes.
if !params.shutdown && Self::is_mt5_running() {
tracing::info!("Backtest {}: killing MT5 after report extraction (shutdown=false)", report_id);
let _ = std::process::Command::new("pkill")
.args(["-TERM", "-f", "terminal64\\.exe"])
.output();
}
Self::update_job_status(&report_dir, "completed", Some(candidate.to_string_lossy().to_string())).await;
if let Some(ref callback) = notification_callback {
callback("backtest_completed", json!({
@@ -402,12 +433,113 @@ impl BacktestPipeline {
}
}
// Inactivity watchdog: check if tester agent log is growing.
// The agent log is written incrementally as the tester processes ticks.
// If it stops growing for `inactivity_threshold` seconds → the test is done
// (or EA is stuck). Either way, we wait 30 s for MT5 to write the HTML
// report, then kill it unconditionally.
//
// NOTE: ShutdownTerminal=1 is supposed to make MT5 exit after the test,
// but on Wine/macOS this is unreliable — terminal64.exe often stays alive
// indefinitely. So we always kill after the HTML-wait window rather than
// depending on natural exit. The 30 s window is long enough for MT5 to flush
// the report file; if it isn't there by then, it won't appear.
if inactivity_enabled && Self::is_mt5_running() {
if let Some(log_path) = Self::find_active_tester_agent_log(&config) {
let current_size = fs::metadata(&log_path)
.map(|m| m.len())
.unwrap_or(0);
if current_size > last_log_size {
last_log_size = current_size;
last_log_activity = tokio::time::Instant::now();
} else if last_log_activity.elapsed() >= inactivity_threshold && last_log_size > 0 {
tracing::info!(
"Backtest {}: tester log inactive for {}s — waiting 30s for HTML report, then killing MT5",
report_id, inactivity_threshold.as_secs()
);
// Poll for the HTML during the grace window (30 s, 1 s intervals).
let reports_parent = expected_report.parent();
let mut html_found: Option<std::path::PathBuf> = None;
for _wait in 0u32..30 {
// Check exact expected path first.
for ext in &["htm", "htm.xml", "html"] {
let candidate = if *ext == "htm" {
expected_report.clone()
} else {
let stem = expected_report.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
expected_report.with_file_name(format!("{}.{}", stem, ext))
};
if candidate.exists() {
html_found = Some(candidate);
break;
}
}
if html_found.is_some() { break; }
// Also scan for any newly created report.
if let Some(parent) = reports_parent {
if let Some(path) = Self::find_newest_report(parent, poll_start) {
html_found = Some(path);
break;
}
}
sleep(Duration::from_secs(1)).await;
}
// Kill MT5 now — it either wrote the report or it won't.
tracing::info!("Backtest {}: killing MT5 after inactivity+HTML-wait window", report_id);
let _ = std::process::Command::new("pkill")
.args(["-TERM", "-f", "terminal64\\.exe"])
.output();
sleep(Duration::from_secs(2)).await;
let _ = std::process::Command::new("pkill")
.args(["-KILL", "-f", "terminal64\\.exe"])
.output();
if let Some(path) = html_found {
tracing::info!("Backtest {}: HTML report found during wait: {}", report_id, path.display());
let extracted = Self::extract_and_store(&path, &report_dir, &report_id, &config, &params).await;
if extracted { let _ = fs::remove_file(&path); }
Self::update_job_status(&report_dir, "completed", Some(path.to_string_lossy().to_string())).await;
if let Some(ref callback) = notification_callback {
callback("backtest_completed", json!({
"report_id": report_id,
"status": "completed"
}));
}
return;
}
// No HTML — fall back to journal extraction.
sleep(Duration::from_secs(1)).await;
if let Some(log) = Self::find_active_tester_agent_log(&config) {
if Self::extract_from_journal(&log, &report_dir, &report_id, &config, &params).await {
Self::update_job_status(&report_dir, "completed_no_html", None).await;
if let Some(ref callback) = notification_callback {
callback("backtest_completed", json!({
"report_id": report_id,
"status": "completed_no_html",
"reason": "extracted from journal after inactivity kill (no HTML produced)"
}));
}
return;
}
}
Self::update_job_status(&report_dir, "timeout_inactive", None).await;
return;
}
}
}
// Check process liveness after grace period
let in_grace = start.elapsed() <= grace_period;
let mt5_alive = Self::is_mt5_running();
if !in_grace && !mt5_alive {
// MT5 exited without report - check for any newer report
// MT5 exited. Poll for the .htm report for up to 10 s (check every 1 s).
// Wine on macOS can take several seconds to flush the file after the
// process exits — a single fixed wait often misses the window.
let reports_parent = match expected_report.parent() {
Some(p) => p,
None => {
@@ -416,13 +548,26 @@ impl BacktestPipeline {
return;
}
};
if let Some(path) = Self::find_newest_report(reports_parent, poll_start) {
tracing::info!("Backtest {} completed: found fallback report {}", report_id, path.display());
let mut found_report: Option<std::path::PathBuf> = None;
for attempt in 1u32..=10 {
sleep(Duration::from_secs(1)).await;
if let Some(path) = Self::find_newest_report(reports_parent, poll_start) {
tracing::info!(
"Backtest {}: found report after {}s — {}",
report_id, attempt, path.display()
);
found_report = Some(path);
break;
}
tracing::debug!("Backtest {}: no report yet ({}s elapsed after MT5 exit)", report_id, attempt);
}
if let Some(path) = found_report {
tracing::info!("Backtest {} completed: found report {}", report_id, path.display());
let extracted = Self::extract_and_store(&path, &report_dir, &report_id, &config, &params).await;
if extracted {
let _ = fs::remove_file(&path);
} else {
tracing::warn!("Backtest {}: extraction failed, keeping fallback report at {}", report_id, path.display());
tracing::warn!("Backtest {}: extraction failed, keeping report at {}", report_id, path.display());
}
Self::update_job_status(&report_dir, "completed", Some(path.to_string_lossy().to_string())).await;
if let Some(ref callback) = notification_callback {
@@ -432,22 +577,46 @@ impl BacktestPipeline {
"status": "completed"
}));
}
} else {
tracing::warn!("Backtest {} failed: MT5 exited without producing a report", report_id);
Self::update_job_status(&report_dir, "failed", None).await;
if let Some(ref callback) = notification_callback {
callback("backtest_failed", json!({
"report_id": report_id,
"status": "failed",
"reason": "MT5 exited without producing a report"
}));
return;
}
// No HTML report found — fallback to journal extraction.
tracing::warn!("Backtest {}: no HTML report found, trying journal extraction", report_id);
if let Some(log) = Self::find_active_tester_agent_log(&config) {
if Self::extract_from_journal(&log, &report_dir, &report_id, &config, &params).await {
Self::update_job_status(&report_dir, "completed_no_html", None).await;
if let Some(ref callback) = notification_callback {
callback("backtest_completed", json!({
"report_id": report_id,
"status": "completed_no_html",
"reason": "extracted from tester journal (HTML report not produced)"
}));
}
return;
}
}
tracing::warn!("Backtest {} failed: MT5 exited without producing a report", report_id);
Self::update_job_status(&report_dir, "failed", None).await;
if let Some(ref callback) = notification_callback {
callback("backtest_failed", json!({
"report_id": report_id,
"status": "failed",
"reason": "MT5 exited without producing a report or recoverable journal"
}));
}
return;
}
if tokio::time::Instant::now() > deadline {
tracing::warn!("Backtest {} timed out after {} seconds", report_id, timeout_secs);
// Last-chance journal extraction on timeout
if let Some(log) = Self::find_active_tester_agent_log(&config) {
if Self::extract_from_journal(&log, &report_dir, &report_id, &config, &params).await {
Self::update_job_status(&report_dir, "completed_no_html", None).await;
return;
}
}
Self::update_job_status(&report_dir, "timeout", None).await;
if let Some(ref callback) = notification_callback {
callback("backtest_timeout", json!({
@@ -463,6 +632,251 @@ impl BacktestPipeline {
}
}
/// Find the best tester agent log file for today.
///
/// Selection priority:
/// 1. Local agents (127.0.0.1) preferred over external/cloud agents (0.0.0.0)
/// — 0.0.0.0 logs only contain startup info, never actual deal lines.
/// 2. Among equal-priority agents, pick the **largest** file (most content).
pub fn find_active_tester_agent_log(config: &Config) -> Option<PathBuf> {
let mt5_dir = config.mt5_dir()?;
let tester_dir = mt5_dir.join("Tester");
let today = chrono::Utc::now().format("%Y%m%d").to_string();
// (priority, size, path) — higher priority = more preferred
// priority 1 = local (127.0.0.1), priority 0 = other (0.0.0.0 / any)
let mut best: Option<(u8, u64, PathBuf)> = None;
if let Ok(agents) = fs::read_dir(&tester_dir) {
for agent in agents.filter_map(|e| e.ok()) {
let agent_name = agent.file_name();
let agent_str = agent_name.to_string_lossy();
// Only consider Agent-* directories
if !agent_str.starts_with("Agent-") {
continue;
}
let logs_dir = agent.path().join("logs");
let candidate = logs_dir.join(format!("{}.log", today));
if !candidate.exists() {
continue;
}
let meta = match fs::metadata(&candidate) {
Ok(m) => m,
Err(_) => continue,
};
let size = meta.len();
// Prefer local agents (127.0.0.1) — they log actual deal execution
let priority: u8 = if agent_str.contains("127.0.0.1") { 1 } else { 0 };
let is_better = match &best {
None => true,
Some((bp, bs, _)) => priority > *bp || (priority == *bp && size > *bs),
};
if is_better {
best = Some((priority, size, candidate));
}
}
}
best.map(|(_, _, p)| p)
}
/// Read the most recent tester agent log and return its lines (UTF-16 or UTF-8).
pub fn read_tester_agent_log(log_path: &Path) -> Option<Vec<String>> {
let bytes = fs::read(log_path).ok()?;
let text = if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
// UTF-16 LE with BOM
let words: Vec<u16> = bytes[2..].chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
String::from_utf16_lossy(&words).to_string()
} else {
String::from_utf8_lossy(&bytes).to_string()
};
Some(text.lines().map(|l| l.to_string()).collect())
}
/// Parse deal entries from a tester agent log.
/// Returns (deals_parsed, final_balance_pips, sim_progress_line).
///
/// Entry direction (in/out) is inferred via a per-symbol position tracker:
/// - No open position → "in"
/// - Same direction as existing position → "in" (grid/martingale add)
/// - Opposite direction → "out" (closing)
/// Profit/balance are unavailable in the journal; they remain 0.0.
pub fn parse_journal_deals(lines: &[String]) -> (Vec<crate::models::deals::Deal>, f64, String) {
use regex::Regex;
use std::collections::HashMap;
// Format: "... YYYY.MM.DD HH:MM:SS deal #N buy/sell VOLUME SYM at PRICE done ..."
let deal_re = Regex::new(
r"(\d{4}\.\d{2}\.\d{2} \d{2}:\d{2}:\d{2})\s+deal #(\d+) (buy|sell) ([\d.]+) (\S+) at ([\d.]+) done"
).unwrap();
let balance_re = Regex::new(r"final balance ([\d.]+) pips").unwrap();
let progress_re = Regex::new(r"Test passed in (.+)").unwrap();
let mut deals = Vec::new();
let mut final_balance = 0.0f64;
let mut progress_str = String::new();
// signed lots per symbol: positive = net long, negative = net short
let mut position: HashMap<String, f64> = HashMap::new();
// MT5 tester logs each deal TWICE (dual-agent logging) — deduplicate by deal number
let mut seen_deals: std::collections::HashSet<String> = std::collections::HashSet::new();
for line in lines {
if let Some(cap) = deal_re.captures(line) {
let sim_time = cap[1].to_string();
let deal_num = cap[2].to_string();
let direction = cap[3].to_string(); // "buy" | "sell"
let volume: f64 = cap[4].parse().unwrap_or(0.0);
let symbol = cap[5].to_string();
let price: f64 = cap[6].parse().unwrap_or(0.0);
// Skip duplicate deal entries (MT5 writes each deal twice)
if !seen_deals.insert(deal_num.clone()) {
continue;
}
let signed = if direction == "buy" { volume } else { -volume };
let current = position.get(&symbol).copied().unwrap_or(0.0);
// Determine entry type by comparing new direction against open position
let entry_type = if current.abs() < 1e-9 {
// flat → opening a new position
"in"
} else if (current > 0.0 && direction == "buy")
|| (current < 0.0 && direction == "sell")
{
// same direction as existing → adding (grid/martingale)
"in"
} else {
// opposite direction → closing / partial close
"out"
};
// Update tracked position
let new_pos = current + signed;
if new_pos.abs() < 1e-9 {
position.remove(&symbol);
} else {
position.insert(symbol.clone(), new_pos);
}
deals.push(crate::models::deals::Deal {
time: sim_time,
deal: deal_num,
symbol,
deal_type: direction,
entry: entry_type.to_string(),
volume,
price,
order: String::new(),
commission: 0.0,
swap: 0.0,
profit: 0.0, // not available in journal
balance: 0.0, // not available in journal
comment: String::new(),
magic: None,
});
}
if let Some(cap) = balance_re.captures(line) {
final_balance = cap[1].parse().unwrap_or(0.0);
}
if let Some(cap) = progress_re.captures(line) {
progress_str = cap[1].to_string();
}
}
(deals, final_balance, progress_str)
}
/// Fallback: extract deals from the tester agent journal log when no HTML report exists.
/// Stores partial deal data (no per-deal P&L) and records the final balance only.
async fn extract_from_journal(
log_path: &Path,
report_dir: &Path,
report_id: &str,
config: &Config,
params: &BacktestParams,
) -> bool {
let lines = match Self::read_tester_agent_log(log_path) {
Some(l) => l,
None => {
tracing::warn!("Journal extraction: could not read log {}", log_path.display());
return false;
}
};
let (deals, final_balance_pips, progress) = Self::parse_journal_deals(&lines);
if deals.is_empty() {
tracing::warn!("Journal extraction: no deals found in {}", log_path.display());
return false;
}
tracing::info!(
"Journal extraction: {} deals, final balance {} pips, {}",
deals.len(), final_balance_pips, progress
);
// Save journal summary to report_dir
let summary_path = report_dir.join("journal_extraction.json");
let summary = json!({
"source": "tester_agent_log",
"log_path": log_path.to_string_lossy(),
"total_deals": deals.len(),
"final_balance_pips": final_balance_pips,
"progress": progress,
"note": "No HTML report was produced. Deals extracted from tester agent log. profit/balance fields are 0 (not available in log format)."
});
let _ = fs::write(&summary_path, serde_json::to_string_pretty(&summary).unwrap_or_default());
// Register in DB with partial metrics
let db = crate::storage::ReportDb::new(&Config::db_path());
if db.init().is_err() {
return false;
}
let entry = crate::storage::ReportEntry {
id: report_id.to_string(),
expert: params.expert.clone(),
symbol: params.symbol.clone(),
timeframe: params.timeframe.clone(),
model: params.model as i64,
from_date: params.from_date.clone(),
to_date: params.to_date.clone(),
created_at: chrono::Utc::now().to_rfc3339(),
set_file_original: params.set_file.clone(),
set_snapshot_path: None,
report_dir: report_dir.to_string_lossy().to_string(),
charts_dir: None,
net_profit: Some(final_balance_pips - params.deposit as f64),
profit_factor: None,
max_dd_pct: None,
sharpe_ratio: None,
total_trades: Some(deals.len() as i64 / 2), // open+close pairs
win_rate_pct: None,
recovery_factor: None,
deposit: Some(params.deposit as f64),
currency: config.backtest_currency.clone(),
leverage: Some(params.leverage as i64),
duration_seconds: None,
tags: vec!["journal-only".to_string()],
notes: Some(format!("Extracted from journal: {} deals, final balance {} pips. No HTML report.", deals.len(), final_balance_pips)),
verdict: None,
};
if db.insert(&entry).is_err() {
return false;
}
if let Err(e) = db.insert_deals(report_id, &deals) {
tracing::warn!("Journal extraction: failed to store deals: {}", e);
}
true
}
/// Update job status in job.json file.
async fn update_job_status(report_dir: &Path, status: &str, report_path: Option<String>) {
let job_path = report_dir.join("job.json");
@@ -706,15 +1120,16 @@ impl BacktestPipeline {
let reports_dir = mt5_dir.join("reports");
fs::create_dir_all(&reports_dir)?;
// Write params via /config: (triggers tester auto-start) and terminal.ini (redundancy).
// Kill first — MT5 writes terminal.ini on exit, which would clobber
// the backtest params we're about to write.
self.kill_mt5().await?;
// Write params *after* MT5 is dead so nothing can overwrite them.
let ini_content = self.build_backtest_ini(params, report_id)?;
let config_host = wine_prefix.join("drive_c").join("backtest_config.ini");
fs::write(&config_host, ini_content.as_bytes())?;
self.update_terminal_ini(params, report_id)?;
// Always kill any running MT5 — Wine allows only one instance per prefix.
self.kill_mt5().await?;
// Record launch time before sleeping so find_newest_report doesn't miss
// reports written during the startup wait.
let poll_start = std::time::SystemTime::now();
@@ -759,8 +1174,13 @@ impl BacktestPipeline {
tracing::info!("poll t+{}s: in_grace={} mt5_alive={}", elapsed, in_grace, mt5_alive);
if !in_grace && !mt5_alive {
// MT5 writes the .htm report file right before exiting. There is a
// short window where the process is gone but the file hasn't been
// flushed to the directory. Wait 3 s to let Wine/macOS finish the
// write before we scan — this prevents false "no report" failures.
sleep(Duration::from_secs(3)).await;
if let Some(path) = Self::find_newest_report(&reports_dir, poll_start) {
tracing::info!("poll: MT5 exited, found fallback report {}", path.display());
tracing::info!("poll: MT5 exited, found report {}", path.display());
return Ok(path);
}
return Err(anyhow!(
@@ -963,23 +1383,16 @@ impl BacktestPipeline {
);
let script_path = std::env::temp_dir().join("mt5_backtest_launch.sh");
// Only rewrite script if it doesn't exist or content differs (optimization)
let needs_write = !script_path.exists() || fs::read_to_string(&script_path).map(|existing| existing != script).unwrap_or(true);
if needs_write {
fs::write(&script_path, &script)?;
// chmod +x
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path,
fs::Permissions::from_mode(0o755))?;
}
tracing::debug!("Created/updated launch script: {}", script_path.display());
} else {
tracing::debug!("Reusing existing launch script: {}", script_path.display());
// Always rewrite: script content changes per backtest (DYLD paths are
// dynamic) and we must ensure +x permissions are set every time.
fs::write(&script_path, &script)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
}
tracing::debug!("Wrote launch script: {}", script_path.display());
tracing::info!("Launching MT5 via shell script (terminal.ini mode): {}", script_path.display());
let mut cmd = Command::new("/bin/sh");
@@ -1072,22 +1485,42 @@ impl BacktestPipeline {
tracing::info!("Stopping existing MT5 instance...");
// SIGKILL immediately — MT5 holds no state we care about preserving.
let mut killed_any = false;
for pat in &patterns {
let result = Command::new("pkill").args(["-KILL", "-f", pat.as_str()]).output();
if result.map(|o| o.status.success()).unwrap_or(false) {
killed_any = true;
let _ = Command::new("pkill").args(["-KILL", "-f", pat.as_str()]).output();
}
// Also kill wineserver so the Wine prefix is fully reset before relaunch.
// If wineserver is still alive when the new MT5 spawns, the new Wine
// instance may attach to the dying server and never enter tester mode.
let _ = Command::new("pkill").args(["-KILL", "-f", "wineserver"]).output();
// Poll until wineserver is actually gone (max 10 s) rather than sleeping
// a fixed amount. On macOS wineserver cleanup varies from 1 s to 6+ s.
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
loop {
sleep(Duration::from_millis(500)).await;
let ws_alive = Command::new("pgrep")
.args(["-f", "wineserver"])
.output()
.map(|o| o.status.success())
.unwrap_or(false);
let mt5_alive = patterns.iter().any(|pat| {
Command::new("pgrep")
.args(["-f", pat.as_str()])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
});
if !ws_alive && !mt5_alive {
tracing::info!("MT5 and wineserver fully exited");
break;
}
if tokio::time::Instant::now() >= deadline {
tracing::warn!("wineserver still alive after 10 s — proceeding anyway");
break;
}
}
let wineserver_result = Command::new("pkill").args(["-KILL", "-f", "wineserver"]).output();
if wineserver_result.map(|o| o.status.success()).unwrap_or(false) {
killed_any = true;
}
// Only sleep if we actually killed something - skip if no processes were running
if killed_any {
sleep(Duration::from_secs(2)).await; // Reduced from 3s to 2s
}
// Brief extra pause to let the kernel release sockets and shared memory.
sleep(Duration::from_millis(500)).await;
Ok(())
}
+19 -1
View File
@@ -99,8 +99,10 @@ pub fn tool_launch_backtest() -> Value {
"skip_compile": { "type": "boolean" },
"skip_clean": { "type": "boolean" },
"timeout": { "type": "integer", "description": "Max time in seconds to wait for backtest (default: 900)" },
"shutdown": { "type": "boolean", "description": "Shut down MT5 after test (default: true — required for HTML report to be written)" },
"gui": { "type": "boolean", "description": "Enable visualization during backtest" },
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" }
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" },
"inactivity_kill_secs": { "type": "integer", "description": "Kill MT5 if tester log hasn't grown for this many seconds (0 = disabled). Use to abort EAs that stop trading mid-test." }
}
}
})
@@ -119,6 +121,22 @@ pub fn tool_get_backtest_status() -> Value {
})
}
pub fn tool_get_tester_log() -> Value {
json!({
"name": "get_tester_log",
"description": "Read the active MT5 tester agent journal log. Returns parsed deals, final balance, test progress, and raw log tail. Works during a backtest (if the log is being written) or after completion. Use this to inspect what trades occurred, check for EA activity, or debug issues when the HTML report wasn't produced.",
"inputSchema": {
"type": "object",
"properties": {
"tail_lines": {
"type": "integer",
"description": "Number of log tail lines to return (default: 100)"
}
}
}
})
}
pub fn tool_cache_status() -> Value {
json!({
"name": "cache_status",
+1
View File
@@ -18,6 +18,7 @@ pub fn get_tools_list() -> Value {
backtest::tool_run_backtest_only(), // Minimal: skip compile, do clean + backtest + extract only
backtest::tool_launch_backtest(), // Fire-and-forget: compile + clean + launch MT5
backtest::tool_get_backtest_status(), // Poll for completion
backtest::tool_get_tester_log(), // Live journal reading mid-backtest or after
backtest::tool_cache_status(),
backtest::tool_clean_cache(),
// Optimization
+21
View File
@@ -0,0 +1,21 @@
use serde_json::{json, Value};
pub fn tool_check_update() -> Value {
json!({
"name": "check_update",
"description": "Check if a newer version of mt5-quant is available on GitHub. A background check runs automatically on the first tool call of each session; this tool returns that cached result instantly or fetches it on demand.",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_update() -> Value {
json!({
"name": "update",
"description": "Download and install the latest mt5-quant binary from GitHub Releases, then replace the current executable in place. Restart the MCP connection after updating to load the new version.",
"inputSchema": {
"type": "object"
}
})
}
+3 -1
View File
@@ -225,7 +225,9 @@ fn deal_to_json(d: &Deal) -> Value {
}
fn is_closed_trade(d: &Deal) -> bool {
d.entry.to_lowercase().contains("out") && d.profit != 0.0
// Accept any "out" entry. Profit may legitimately be 0.0 for journal-extracted
// deals (where per-deal P&L is unavailable), so we don't gate on profit != 0.0.
d.entry.to_lowercase().contains("out")
}
pub async fn handle_list_deals(_config: &Config, args: &Value) -> Result<Value> {
+239 -77
View File
@@ -74,66 +74,87 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value>
.and_then(|v| v.as_str())
.unwrap_or("");
let symbol = if requested_symbol.is_empty() {
// 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 {
// No tester data at all → hard fail with helpful context
let no_symbols_error = || json!({
"content": [{ "type": "text", "text": json!({
"error": format!("No symbols available for backtesting on server '{}'.", active_server),
"account": { "login": active_login, "server": active_server },
"hint": "Open MT5 → View → Strategy Tester → download history for at least one symbol.",
"pre_check": "no_symbols"
}).to_string() }],
"isError": true
});
let symbol: String = if requested_symbol.is_empty() {
// No symbol requested — use config default or first available tester symbol.
let candidate = config.backtest_symbol.as_deref().unwrap_or("");
if candidate.is_empty() {
preflight.available_symbols.first()
.cloned()
.ok_or(()
).unwrap_or_else(|_| return String::new())
} else {
match Config::resolve_symbol(candidate, &preflight.available_symbols) {
Some(resolved) => {
if resolved != candidate {
tracing::warn!(
"Config symbol '{}' not in tester data for '{}'; using '{}' instead",
candidate, active_server, resolved
);
}
resolved.to_string()
}
None => {
// Config default has no tester data — pick first available
preflight.available_symbols.first()
.cloned()
.unwrap_or_default()
}
}
}
} else {
// Caller specified a symbol — resolve it against actual tester data.
if preflight.available_symbols.is_empty() {
return Ok(no_symbols_error());
}
match Config::resolve_symbol(requested_symbol, &preflight.available_symbols) {
Some(resolved) if resolved == requested_symbol => {
// Exact match — use as-is
resolved.to_string()
}
Some(resolved) => {
// Fuzzy match — proceed with the corrected symbol, surface the substitution
tracing::warn!(
"Symbol '{}' not in tester data for '{}'; substituting '{}'",
requested_symbol, active_server, resolved
);
resolved.to_string()
}
None => {
// No match at all — fail with full context so the caller can act
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."
"error": format!(
"Symbol '{}' has no tester data on server '{}' and no close match was found.",
requested_symbol, active_server
),
"account": { "login": active_login, "server": active_server },
"requested_symbol": requested_symbol,
"available_symbols": preflight.available_symbols,
"hint": "The tester data for this symbol hasn't been downloaded yet. \
Open MT5 Strategy Tester select the symbol and click Download.",
"pre_check": "symbol_not_available"
}).to_string() }],
"isError": true
}));
}
} else if let Some(first) = preflight.available_symbols.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 !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()
};
// Guard against the empty-string edge case (no symbols at all)
if symbol.is_empty() {
return Ok(no_symbols_error());
}
// EA existence check with context
if !preflight.ea_exists {
@@ -172,11 +193,12 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value>
skip_clean: args.get("skip_clean").and_then(|v| v.as_bool()).unwrap_or(false),
skip_analyze: args.get("skip_analyze").and_then(|v| v.as_bool()).unwrap_or(false),
deep_analyze: args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false),
shutdown: args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(false),
shutdown: args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(true),
kill_existing: args.get("kill_existing").and_then(|v| v.as_bool()).unwrap_or(false),
timeout: args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900),
gui: args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false),
startup_delay_secs: args.get("startup_delay_secs").and_then(|v| v.as_u64()).unwrap_or(0),
inactivity_kill_secs: args.get("inactivity_kill_secs").and_then(|v| v.as_u64()),
};
let pipeline = BacktestPipeline::new(config.clone());
@@ -193,24 +215,26 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value>
}))
}
pub async fn handle_run_backtest_quick(config: &Config, args: &Value) -> Result<Value> {
// Quick backtest: skip compile, do clean → backtest → extract → analyze
pub async fn handle_run_backtest_quick(handler: &crate::tools::handlers::ToolHandler, args: &Value) -> Result<Value> {
// Quick backtest: skip compile, clean → launch → background monitor → return job.
// Uses the fire-and-forget launch path so the MCP response is returned immediately
// and the result is available via get_backtest_status / get_latest_report once done.
// (The synchronous blocking path exceeds MCP request timeouts for any backtest >2 min.)
let mut args = args.clone();
if let Some(obj) = args.as_object_mut() {
obj.insert("skip_compile".to_string(), json!(true));
// keep skip_analyze as false (default) to run analysis
}
handle_run_backtest(config, &args).await
handle_launch_backtest(handler, &args).await
}
pub async fn handle_run_backtest_only(config: &Config, args: &Value) -> Result<Value> {
// Backtest only: skip compile, skip analyze - just backtest and extract
pub async fn handle_run_backtest_only(handler: &crate::tools::handlers::ToolHandler, args: &Value) -> Result<Value> {
// Backtest only: skip compile and analyze — launch and return job immediately.
let mut args = args.clone();
if let Some(obj) = args.as_object_mut() {
obj.insert("skip_compile".to_string(), json!(true));
obj.insert("skip_analyze".to_string(), json!(true));
}
handle_run_backtest(config, &args).await
handle_launch_backtest(handler, &args).await
}
pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandler, args: &Value) -> Result<Value> {
@@ -232,17 +256,50 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle
}));
}
// Get symbol
// Get symbol — resolve against actual tester data (same logic as handle_run_backtest)
let active_server = preflight.server.as_deref().unwrap_or("unknown");
let requested_symbol = args.get("symbol")
.and_then(|v| v.as_str())
.unwrap_or("");
let symbol = if requested_symbol.is_empty() {
handler.config.backtest_symbol.clone()
.or_else(|| preflight.available_symbols.first().cloned())
.unwrap_or_else(|| "EURUSD".to_string())
let symbol: String = if requested_symbol.is_empty() {
let candidate = handler.config.backtest_symbol.as_deref().unwrap_or("");
if candidate.is_empty() {
preflight.available_symbols.first().cloned().unwrap_or_default()
} else {
Config::resolve_symbol(candidate, &preflight.available_symbols)
.map(|s| s.to_string())
.or_else(|| preflight.available_symbols.first().cloned())
.unwrap_or_else(|| candidate.to_string())
}
} else {
requested_symbol.to_string()
match Config::resolve_symbol(requested_symbol, &preflight.available_symbols) {
Some(resolved) => {
if resolved != requested_symbol {
tracing::warn!(
"launch_backtest: symbol '{}' not in tester data for '{}'; using '{}'",
requested_symbol, active_server, resolved
);
}
resolved.to_string()
}
None if preflight.available_symbols.is_empty() => requested_symbol.to_string(),
None => {
return Ok(json!({
"content": [{ "type": "text", "text": json!({
"error": format!(
"Symbol '{}' has no tester data on server '{}' and no close match was found.",
requested_symbol, active_server
),
"requested_symbol": requested_symbol,
"available_symbols": preflight.available_symbols,
"hint": "Open MT5 → Strategy Tester → select the symbol and click Download.",
"pre_check": "symbol_not_available"
}).to_string() }],
"isError": true
}));
}
}
};
// EA existence check
@@ -281,11 +338,17 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle
skip_clean: args.get("skip_clean").and_then(|v| v.as_bool()).unwrap_or(false),
skip_analyze: true, // Not needed for launch mode
deep_analyze: false,
shutdown: false, // Don't shutdown so we can poll
// On Wine/macOS, ShutdownTerminal=1 does NOT reliably cause terminal64.exe to exit.
// When inactivity_kill_secs is set, the monitor waits that many seconds after the
// tester log goes quiet, then polls for the HTML report for 30 s, then kills MT5.
// If no inactivity_kill_secs is given (default None → disabled), the monitor relies
// solely on timeout (900 s) or natural MT5 exit for completion detection.
shutdown: args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(true),
kill_existing: false,
timeout: args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900),
gui: args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false),
startup_delay_secs: args.get("startup_delay_secs").and_then(|v| v.as_u64()).unwrap_or(0),
inactivity_kill_secs: args.get("inactivity_kill_secs").and_then(|v| v.as_u64()),
};
let pipeline = if let Some(ref callback) = handler.notification_callback {
@@ -348,15 +411,27 @@ pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Resul
// Check if MT5 is running
let mt5_running = is_mt5_running();
// Check if report file exists
// Check if report file exists (still on disk — it's deleted after extraction)
let report_found = job.as_ref()
.map(|j| Path::new(&j.expected_report_path).exists())
.unwrap_or(false);
// Check for completed artifacts
let metrics_exists = report_path.join("metrics.json").exists();
let is_complete = stage == "DONE" || (report_found && metrics_exists);
// Read the authoritative status written by the background monitor into job.json.
// This avoids false "failed" when the HTML was extracted+deleted (report_found=false)
// or when journal extraction ran instead of HTML extraction.
let job_status = job.as_ref()
.and_then(|j| j.status.as_deref())
.unwrap_or("");
let monitor_says_complete = matches!(job_status, "completed" | "completed_no_html");
let monitor_says_failed = matches!(job_status, "failed" | "timeout" | "timeout_inactive");
let is_complete = monitor_says_complete
|| stage == "DONE"
|| (report_found && metrics_exists);
// Calculate elapsed time if job exists
let elapsed_seconds = job.as_ref()
.and_then(|j| {
@@ -365,26 +440,32 @@ pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Resul
.map(|t| (chrono::Utc::now() - t.with_timezone(&chrono::Utc)).num_seconds())
})
.unwrap_or(0);
// Determine status message
// Determine status message — trust monitor's job.json first
let status_msg = if is_complete {
"completed"
} else if monitor_says_failed {
if job_status == "timeout" || job_status == "timeout_inactive" { "timeout" } else { "failed" }
} else if stage == "BACKTEST" && mt5_running {
"running"
} else if stage == "BACKTEST" && !mt5_running && !report_found {
} else if stage == "BACKTEST" && !mt5_running {
"failed"
} else if progress_lines > 0 {
"in_progress"
} else {
"not_started"
};
let message = if is_complete {
"Backtest completed successfully"
if job_status == "completed_no_html" {
"Backtest completed (extracted from tester journal — no HTML report)"
} else {
"Backtest completed successfully"
}
} else if stage == "BACKTEST" && mt5_running {
"MT5 is running the backtest"
} else if stage == "BACKTEST" && !mt5_running {
"MT5 process exited but report not found - backtest may have failed"
"MT5 process exited report not yet found"
} else {
&format!("Backtest is at stage: {}", stage)
};
@@ -433,6 +514,87 @@ fn is_mt5_running() -> bool {
})
}
/// Read the active tester agent log for live/post-test deal inspection.
/// Returns the last N lines and a parsed deal summary.
pub async fn handle_get_tester_log(config: &Config, args: &Value) -> Result<Value> {
use crate::pipeline::backtest::BacktestPipeline;
let tail_lines = args.get("tail_lines").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
let log_path = match BacktestPipeline::find_active_tester_agent_log(config) {
Some(p) => p,
None => {
return Ok(json!({
"content": [{ "type": "text", "text": json!({
"error": "No tester agent log found for today.",
"hint": "Run a backtest first. The log appears after the tester starts."
}).to_string() }],
"isError": true
}));
}
};
let lines = match BacktestPipeline::read_tester_agent_log(&log_path) {
Some(l) => l,
None => {
return Ok(json!({
"content": [{ "type": "text", "text": json!({
"error": format!("Could not read log at {}", log_path.display())
}).to_string() }],
"isError": true
}));
}
};
let (deals, final_balance_pips, progress) = BacktestPipeline::parse_journal_deals(&lines);
// Collect tail lines
let tail: Vec<&str> = lines.iter()
.rev()
.take(tail_lines)
.rev()
.map(|s| s.as_str())
.collect();
// Detect last sim timestamp for progress estimation
let last_sim_time = lines.iter().rev()
.find_map(|l| {
let parts: Vec<&str> = l.split_whitespace().collect();
// Format: XX 0 HH:MM:SS.mmm Core NN YYYY.MM.DD HH:MM:SS ...
if parts.len() >= 6 {
let date = parts[4];
let time = parts[5];
if date.contains('.') && time.contains(':') {
return Some(format!("{} {}", date, time));
}
}
None
})
.unwrap_or_default();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"log_path": log_path.to_string_lossy(),
"total_lines": lines.len(),
"deals_found": deals.len(),
"final_balance_pips": final_balance_pips,
"progress": progress,
"last_sim_time": last_sim_time,
"is_complete": !progress.is_empty(),
"tail_lines": tail,
"deals_summary": deals.iter().map(|d| json!({
"deal": d.deal,
"time": d.time,
"type": d.deal_type,
"volume": d.volume,
"price": d.price,
"symbol": d.symbol,
})).collect::<Vec<_>>()
}).to_string() }],
"isError": false
}))
}
pub async fn handle_cache_status(config: &Config) -> Result<Value> {
let cache_dir = config.tester_cache_dir.as_ref()
.map(|s| Path::new(s))
+3 -2
View File
@@ -70,10 +70,11 @@ impl ToolHandler {
// Backtest handlers - Granular pipeline options
"run_backtest" => backtest::handle_run_backtest(&self.config, args).await, // Full: compile + clean + backtest + extract + analyze
"run_backtest_quick" => backtest::handle_run_backtest_quick(&self.config, args).await, // Quick: skip compile, do backtest + extract + analyze
"run_backtest_only" => backtest::handle_run_backtest_only(&self.config, args).await, // Minimal: skip compile, do backtest + extract only
"run_backtest_quick" => backtest::handle_run_backtest_quick(self, args).await, // Quick: skip compile, fire-and-forget launch
"run_backtest_only" => backtest::handle_run_backtest_only(self, args).await, // Minimal: skip compile+analyze, fire-and-forget launch
"launch_backtest" => backtest::handle_launch_backtest(self, args).await, // Fire-and-forget mode
"get_backtest_status" => backtest::handle_get_backtest_status(&self.config, args).await,
"get_tester_log" => backtest::handle_get_tester_log(&self.config, args).await,
"cache_status" => backtest::handle_cache_status(&self.config).await,
"clean_cache" => backtest::handle_clean_cache(&self.config, args).await,
-1
View File
@@ -27,7 +27,6 @@ pub async fn handle_run_optimization(config: &Config, args: &Value) -> Result<Va
from_date: from_date.to_string(),
to_date: to_date.to_string(),
deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32,
model: 0,
leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32,
currency: args.get("currency").and_then(|v| v.as_str()).unwrap_or("USD").to_string(),
};
+29 -12
View File
@@ -3,16 +3,32 @@ use serde_json::{json, Value};
use std::fs;
use crate::models::Config;
/// Read a file that may be UTF-16LE (with BOM) or UTF-8, returning a UTF-8 String.
fn read_file_as_utf8(path: &str) -> Result<String> {
let bytes = fs::read(path)?;
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
let utf16_data: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
String::from_utf16(&utf16_data)
.map_err(|e| anyhow::anyhow!("Failed to decode UTF-16LE: {}", e))
} else {
String::from_utf8(bytes)
.map_err(|e| anyhow::anyhow!("Failed to decode as UTF-8: {}", e))
}
}
pub async fn handle_read_set_file(args: &Value) -> Result<Value> {
let path = args.get("path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("path is required"))?;
let content = fs::read_to_string(path)?;
let content = read_file_as_utf8(path)?;
let mut params = serde_json::Map::new();
for line in content.lines() {
if let Some((key, value)) = line.split_once(':') {
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
@@ -86,7 +102,7 @@ pub async fn handle_patch_set_file(args: &Value) -> Result<Value> {
.and_then(|v| v.as_object())
.ok_or_else(|| anyhow::anyhow!("patches object is required"))?;
let content = fs::read_to_string(path)?;
let content = read_file_as_utf8(path)?;
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
let mut patched_count = 0;
@@ -164,8 +180,8 @@ pub async fn handle_diff_set_files(args: &Value) -> Result<Value> {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("file_b is required"))?;
let content_a = fs::read_to_string(file_a)?;
let content_b = fs::read_to_string(file_b)?;
let content_a = read_file_as_utf8(file_a)?;
let content_b = read_file_as_utf8(file_b)?;
let mut differences = Vec::new();
@@ -223,20 +239,21 @@ pub async fn handle_describe_sweep(args: &Value) -> Result<Value> {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("path is required"))?;
let content = fs::read_to_string(path)?;
let content = read_file_as_utf8(path)?;
let mut sweep_params = serde_json::Map::new();
for line in content.lines() {
if let Some((key, value)) = line.split_once(':') {
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
if value.contains("||Y") {
if let Some((from_val, to_val)) = value.split_once("..") {
let parts: Vec<&str> = value.split("||").collect();
if parts.len() >= 5 && parts[4].trim().to_uppercase() == "Y" {
sweep_params.insert(key.to_string(), json!({
"from": from_val.trim(),
"to": to_val.trim().replace("||Y", ""),
"step": 1.0
"from": parts[1].trim(),
"to": parts[3].trim(),
"step": parts[2].trim(),
}));
}
}
@@ -266,7 +283,7 @@ pub async fn handle_list_set_files(config: &Config) -> Result<Value> {
.unwrap_or("unknown")
.to_string();
let content = fs::read_to_string(&path).unwrap_or_default();
let content = read_file_as_utf8(&path.to_string_lossy()).unwrap_or_default();
let param_count = content.lines().filter(|l| l.contains(':')).count();
let sweep_count = content.lines().filter(|l| l.contains("||Y")).count();
+186 -64
View File
@@ -2,9 +2,61 @@ use anyhow::{Context, Result};
use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::models::Config;
use crate::storage::ReportDb;
/// OS detection and information
#[derive(Debug, Clone)]
#[allow(dead_code)]
enum OsType {
Windows,
MacOS,
Linux,
Unknown,
}
impl OsType {
fn detect() -> Self {
#[cfg(target_os = "windows")]
return OsType::Windows;
#[cfg(target_os = "macos")]
return OsType::MacOS;
#[cfg(target_os = "linux")]
return OsType::Linux;
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
return OsType::Unknown;
}
fn as_str(&self) -> &'static str {
match self {
OsType::Windows => "windows",
OsType::MacOS => "macos",
OsType::Linux => "linux",
OsType::Unknown => "unknown",
}
}
fn uses_wine(&self) -> bool {
matches!(self, OsType::MacOS | OsType::Linux)
}
}
/// Detect if using CrossOver instead of Wine on macOS
fn detect_crossover(wine_exe: &str) -> bool {
wine_exe.contains("crossover") || wine_exe.contains("CrossOver")
}
/// Get OS context for diagnostic outputs
fn get_os_context() -> serde_json::Value {
let os_type = OsType::detect();
json!({
"os": os_type.as_str(),
"uses_wine": os_type.uses_wine(),
"architecture": std::env::consts::ARCH,
})
}
/// Validate that `user_path` resolves to a location within `allowed_base`.
/// Returns the canonicalized absolute path on success.
fn safe_output_path(user_path: &str, allowed_base: &Path) -> Result<PathBuf> {
@@ -846,8 +898,11 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result<Valu
/// Diagnose Wine installation and prefix health
pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Value> {
let os_type = OsType::detect();
let mut diagnostics = json!({
"os_context": get_os_context(),
"wine_executable": null,
"wine_type": null,
"wine_version": null,
"wine_prefix": null,
"prefix_health": null,
@@ -857,12 +912,16 @@ pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Valu
"warnings": Vec::<String>::new(),
});
// Check wine executable
// Check wine executable (macOS/Linux)
if let Some(wine_exe) = config.wine_executable.as_ref() {
diagnostics["wine_executable"] = json!(wine_exe);
// Get Wine version
let version_output = std::process::Command::new(wine_exe)
// Detect CrossOver vs Wine
let is_crossover = detect_crossover(wine_exe);
diagnostics["wine_type"] = json!(if is_crossover { "crossover" } else { "wine" });
// Get Wine/CrossOver version
let version_output = Command::new(wine_exe)
.arg("--version")
.output();
@@ -872,14 +931,26 @@ pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Valu
diagnostics["wine_version"] = json!(version);
}
_ => {
let wine_type_str = if is_crossover { "CrossOver" } else { "Wine" };
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Failed to get Wine version - Wine may not be properly installed")
json!(format!("Failed to get {} version - may not be properly installed", wine_type_str))
);
}
}
// macOS-specific CrossOver checks
if matches!(os_type, OsType::MacOS) && is_crossover {
// Check if CrossOver app exists
let crossover_app = Path::new("/Applications/CrossOver.app");
if !crossover_app.exists() {
diagnostics["warnings"].as_array_mut().unwrap().push(
json!("CrossOver.app not found in /Applications - may be custom installation")
);
}
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Wine executable not configured")
json!("Wine/CrossOver executable not configured")
);
}
@@ -991,6 +1062,7 @@ pub async fn handle_get_mt5_logs(config: &Config, args: &Value) -> Result<Value>
};
let mut result = json!({
"os_context": get_os_context(),
"log_type": log_type,
"log_path": log_path.to_string_lossy().to_string(),
"found": false,
@@ -1109,6 +1181,7 @@ pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result<V
}
let result = json!({
"os_context": get_os_context(),
"hours_searched": hours_back,
"errors_found": errors_found.len(),
"max_errors": max_errors,
@@ -1128,16 +1201,17 @@ pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result<V
/// Check MT5 process status
pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result<Value> {
use std::process::Command;
let _os_type = OsType::detect();
let mut result = json!({
"os_context": get_os_context(),
"is_running": false,
"processes": Vec::<serde_json::Value>::new(),
"wine_server_running": false,
"total_instances": 0,
});
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
// Check for MT5 processes
let ps_output = Command::new("ps")
@@ -1149,6 +1223,7 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result
let mut processes = Vec::new();
let mut mt5_count = 0;
let mut wine_server = false;
let mut crossover_server = false;
for line in content.lines() {
let line_lower = line.to_lowercase();
@@ -1169,45 +1244,11 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result
if line_lower.contains("wineserver") {
wine_server = true;
}
}
result["processes"] = json!(processes);
result["is_running"] = json!(mt5_count > 0);
result["total_instances"] = json!(mt5_count);
result["wine_server_running"] = json!(wine_server);
}
}
#[cfg(target_os = "linux")]
{
let ps_output = Command::new("ps")
.args(["aux"])
.output();
if let Ok(output) = ps_output {
let content = String::from_utf8_lossy(&output.stdout);
let mut processes = Vec::new();
let mut mt5_count = 0;
let mut wine_server = false;
for line in content.lines() {
let line_lower = line.to_lowercase();
if line_lower.contains("terminal64") || line_lower.contains("metatrader") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 11 {
processes.push(json!({
"pid": parts[1],
"cpu": parts[2],
"mem": parts[3],
"command": parts[10..].join(" "),
}));
mt5_count += 1;
}
}
if line_lower.contains("wineserver") {
wine_server = true;
// Detect CrossOver server on macOS
#[cfg(target_os = "macos")]
if line_lower.contains("cxstart") || line_lower.contains("crossover") {
crossover_server = true;
}
}
@@ -1215,6 +1256,11 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result
result["is_running"] = json!(mt5_count > 0);
result["total_instances"] = json!(mt5_count);
result["wine_server_running"] = json!(wine_server);
#[cfg(target_os = "macos")]
{
result["crossover_server_running"] = json!(crossover_server);
}
}
}
@@ -1226,7 +1272,7 @@ pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result
/// Kill stuck MT5 process
pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<Value> {
use std::process::Command;
let _os_type = OsType::detect();
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
let pid = args.get("pid").and_then(|v| v.as_str());
@@ -1271,6 +1317,12 @@ pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<V
// Also kill wineserver if force=true
if force {
let _ = Command::new("killall").arg("wineserver").output();
// On macOS, also kill CrossOver processes
#[cfg(target_os = "macos")]
{
let _ = Command::new("killall").arg("cxstart").output();
}
}
}
@@ -1281,6 +1333,7 @@ pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<V
};
let result = json!({
"os_context": get_os_context(),
"killed": killed,
"failed": failed,
"force": force,
@@ -1295,9 +1348,10 @@ pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<V
/// Check system resources for MT5
pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> Result<Value> {
use std::process::Command;
let _os_type = OsType::detect();
let mut result = json!({
"os_context": get_os_context(),
"disk_space": null,
"memory": null,
"cpu_cores": 0,
@@ -1345,34 +1399,66 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R
let vm_output = Command::new("vm_stat").output();
if let Ok(output) = vm_output {
let content = String::from_utf8_lossy(&output.stdout);
// Parse vm_stat output
// Parse vm_stat output with improved robustness
let mut free_pages = 0u64;
let mut active_pages = 0u64;
let mut inactive_pages = 0u64;
let mut wired_pages = 0u64;
let mut page_size = 4096u64;
// Try to get page size from vm_stat output
for line in content.lines() {
if line.contains("Pages free:") {
free_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
} else if line.contains("Pages active:") {
active_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
} else if line.contains("Pages inactive:") {
inactive_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
if line.contains("Mach Virtual Memory Statistics:") {
// Page size is typically 4096 on macOS
page_size = 4096;
}
}
let page_size = 4096u64;
let total_mb = ((free_pages + active_pages + inactive_pages) * page_size) / 1024 / 1024;
for line in content.lines() {
// More robust parsing using regex-like pattern matching
if line.contains("Pages free:") {
if let Some(val) = line.split(':').nth(1) {
free_pages = val.trim().trim_end_matches('.').parse().unwrap_or(0);
}
} else if line.contains("Pages active:") {
if let Some(val) = line.split(':').nth(1) {
active_pages = val.trim().trim_end_matches('.').parse().unwrap_or(0);
}
} else if line.contains("Pages inactive:") {
if let Some(val) = line.split(':').nth(1) {
inactive_pages = val.trim().trim_end_matches('.').parse().unwrap_or(0);
}
} else if line.contains("Pages wired:") {
if let Some(val) = line.split(':').nth(1) {
wired_pages = val.trim().trim_end_matches('.').parse().unwrap_or(0);
}
}
}
// Calculate memory more accurately
let total_pages = free_pages + active_pages + inactive_pages + wired_pages;
let total_mb = (total_pages * page_size) / 1024 / 1024;
let free_mb = (free_pages * page_size) / 1024 / 1024;
let available_mb = ((free_pages + inactive_pages) * page_size) / 1024 / 1024;
result["memory"] = json!({
"total_mb": total_mb,
"free_mb": free_mb,
"available_mb": available_mb,
"active_mb": (active_pages * page_size) / 1024 / 1024,
"wired_mb": (wired_pages * page_size) / 1024 / 1024,
"page_size": page_size,
"unit": "MB",
});
if free_mb < 2048 {
// Adjust memory threshold for macOS (compressed memory makes free appear lower)
if available_mb < 1024 {
result["recommendations"].as_array_mut().unwrap().push(
json!("Low memory available. MT5 may crash during large optimizations.")
json!(format!("Low memory available ({} MB free). MT5 may crash during large optimizations. Close other apps.", available_mb))
);
} else if available_mb < 2048 {
result["recommendations"].as_array_mut().unwrap().push(
json!(format!("Memory getting low ({} MB available). Consider closing other applications.", available_mb))
);
}
}
@@ -1393,6 +1479,13 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R
"free_mb": parts[3].parse::<u64>().unwrap_or(0),
"unit": "MB",
});
let free_mb = parts[3].parse::<u64>().unwrap_or(0);
if free_mb < 1024 {
result["recommendations"].as_array_mut().unwrap().push(
json!("Low memory available. MT5 may crash during large optimizations.")
);
}
}
}
}
@@ -1400,13 +1493,38 @@ pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> R
}
// Get CPU cores
let nproc_output = Command::new("sysctl")
.args(["-n", "hw.ncpu"])
.output();
#[cfg(target_os = "macos")]
{
let nproc_output = Command::new("sysctl")
.args(["-n", "hw.ncpu"])
.output();
if let Ok(output) = nproc_output {
let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0);
result["cpu_cores"] = json!(cores);
if cores < 4 {
result["recommendations"].as_array_mut().unwrap().push(
json!(format!("Low CPU core count ({} cores). Optimizations may be slow.", cores))
);
}
}
}
if let Ok(output) = nproc_output {
let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0);
result["cpu_cores"] = json!(cores);
#[cfg(target_os = "linux")]
{
let nproc_output = Command::new("nproc").output();
if let Ok(output) = nproc_output {
let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0);
result["cpu_cores"] = json!(cores);
if cores < 4 {
result["recommendations"].as_array_mut().unwrap().push(
json!(format!("Low CPU core count ({} cores). Optimizations may be slow.", cores))
);
}
}
}
}
@@ -1422,6 +1540,7 @@ pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Resul
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let mut result = json!({
"os_context": get_os_context(),
"terminal_ini": null,
"tester_ini": null,
"config_files_found": Vec::<String>::new(),
@@ -1505,6 +1624,7 @@ pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Resul
/// Get Wine prefix detailed information
pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Result<Value> {
let _os_type = OsType::detect();
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
@@ -1514,6 +1634,7 @@ pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Resu
.and_then(|p| p.parent());
let mut result = json!({
"os_context": get_os_context(),
"prefix_path": null,
"exists": false,
"windows_version": null,
@@ -1609,6 +1730,7 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re
let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(6);
let mut result = json!({
"os_context": get_os_context(),
"crashes_found": Vec::<serde_json::Value>::new(),
"recent_failures": 0,
"common_patterns": Vec::<String>::new(),
+20
View File
@@ -0,0 +1,20 @@
/// Read a file that may be UTF-16LE (with BOM) or UTF-8, returning a UTF-8 String.
/// MT5 .set and .ini files are typically UTF-16LE with BOM (0xFF 0xFE).
pub fn read_file_as_utf8(path: &std::path::Path) -> anyhow::Result<String> {
let bytes = std::fs::read(path)?;
// Check for UTF-16LE BOM (0xFF 0xFE)
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
// UTF-16LE with BOM - skip the 2-byte BOM and decode
let utf16_data: Vec<u16> = bytes[2..]
.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect();
String::from_utf16(&utf16_data)
.map_err(|e| anyhow::anyhow!("Failed to decode UTF-16LE: {}", e))
} else {
// Try UTF-8
String::from_utf8(bytes)
.map_err(|e| anyhow::anyhow!("Failed to decode as UTF-8: {}", e))
}
}