Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfb45b8afc | |||
| c0626572f3 | |||
| 9c65cfbede | |||
| 552e271d08 | |||
| d2fca8ded3 | |||
| 55eea0c9db | |||
| d4a65b6808 | |||
| 0dc5ce0c0c | |||
| 1d817c9bed | |||
| 17bb7545ba | |||
| 52c3d91639 | |||
| e954883189 | |||
| 50025968ff | |||
| 6ccc066b6a | |||
| 3ad67d4c57 | |||
| 4c34ae236c | |||
| ecf5606b7a | |||
| 6bfb330b5d | |||
| 52a3d44b99 | |||
| caabc2c03b | |||
| 62c1ca773e | |||
| 3b9d4ce2dd | |||
| 12fba8ad4b | |||
| b6171d0c56 | |||
| 05214ff34b | |||
| 498126dfd4 | |||
| 147da213d6 | |||
| 804c1f8808 | |||
| f8b6a11bb8 | |||
| e82bb5466b | |||
| 50ea45f8cd | |||
| 4bc5ca22e9 | |||
| e44345a05f |
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: masdevid
|
||||
custom: ["https://www.paypal.me/devidhw", "https://saweria.co/depod"]
|
||||
@@ -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"
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
Vendored
-8
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"json.schemas": [
|
||||
{
|
||||
"fileMatch": ["server.json"],
|
||||
"url": "https://raw.githubusercontent.com/modelcontextprotocol/specification/main/schema/2024-11-05/schema.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1,5 +1,61 @@
|
||||
# Changelog
|
||||
|
||||
## [1.34.0] — 2026-07-02
|
||||
|
||||
- docs: update changelog and tool documentation for rolling backtest
|
||||
- feat: add rolling backtest and optimization improvements
|
||||
- fix: sync Cargo.lock, remove unused Agents config from optimizer
|
||||
- fix: sync Cargo.lock version, remove unused agents config from optimizer
|
||||
|
||||
|
||||
## [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.
|
||||
- **Removed unused local agent config**: stripped the `[Agents]` section (4 entries with UUIDs/IPs/ports) from `terminal.ini` output — no local agent processes are used.
|
||||
- **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.
|
||||
- **Optimizer `/config:` INI**: now includes `Model=4` and `Period=M1` in the test-configuration passed via `/config:` flag (previously hardcoded in the older INI-based approach).
|
||||
- **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
|
||||
- **Cargo.lock sync**: lockfile version field is now kept in sync with the release process.
|
||||
- **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
@@ -481,7 +481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mt5-quant"
|
||||
version = "1.32.0"
|
||||
version = "1.34.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mt5-quant"
|
||||
version = "1.32.0"
|
||||
version = "1.34.0"
|
||||
edition = "2021"
|
||||
description = "MCP server for MT5 strategy development on macOS/Linux"
|
||||
authors = ["masdevid <masdevid@example.com>"]
|
||||
@@ -11,6 +11,8 @@ keywords = ["mt5", "mql5", "trading", "mcp", "backtest"]
|
||||
categories = ["finance", "development-tools"]
|
||||
homepage = "https://github.com/masdevid/mt5-quant"
|
||||
documentation = "https://github.com/masdevid/mt5-quant"
|
||||
|
||||
[package.metadata]
|
||||
maintenance = { status = "actively-developed" }
|
||||
|
||||
[[bin]]
|
||||
|
||||
@@ -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 | 200–500ms |
|
||||
| **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 2–5 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
@@ -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
@@ -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
@@ -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)
|
||||
+157
-12
@@ -2,7 +2,7 @@
|
||||
|
||||
Full input/output schemas for MT5-Quant tools.
|
||||
|
||||
> **Documentation Status:** All 89 tools are documented.
|
||||
> **Documentation Status:** All 90 tools are documented.
|
||||
|
||||
---
|
||||
|
||||
@@ -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).
|
||||
}
|
||||
```
|
||||
|
||||
@@ -211,6 +219,137 @@ Fire-and-forget mode: compile → clean → launch MT5 backtest, return immediat
|
||||
|
||||
---
|
||||
|
||||
## `run_rolling_backtest`
|
||||
|
||||
Run N consecutive weekly backtests sequentially and return aggregated results. Compiles once, then each week runs with `skip_compile`. Kills and restarts MT5 between weeks for a clean state.
|
||||
|
||||
**When to call:** When you want to test EA stability across multiple weeks to detect performance degradation, regime change sensitivity, or parameter drift.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
// Required
|
||||
expert: string; // EA name without path or extension
|
||||
|
||||
// Date range — specify both or omit for auto-calculation (N weeks back to last Sunday)
|
||||
from_date?: string; // "YYYY.MM.DD" (default: auto-calculate N weeks back)
|
||||
to_date?: string; // "YYYY.MM.DD" (default: auto-calculate to last Sunday)
|
||||
|
||||
// Optional overrides
|
||||
symbol?: string; // Trading symbol (default: from config or first available)
|
||||
timeframe?: "M1" | "M5" | "M15" | "M30" | "H1" | "H4" | "D1"; // Default: M5
|
||||
deposit?: number; // Initial deposit (default: 10000)
|
||||
model?: 0 | 1 | 2; // Tick model: 0=Every tick, 1=OHLC, 2=Open prices
|
||||
set_file?: string; // Path to .set parameter file for EA inputs
|
||||
|
||||
// Rolling options
|
||||
weeks?: number; // Number of weekly backtests to run (default: 4, max: 52)
|
||||
|
||||
// Pipeline flags
|
||||
skip_compile?: boolean; // Skip initial compilation (default: false — compiles on first week)
|
||||
shutdown?: boolean; // Close MT5 after backtest completes (default: true)
|
||||
kill_existing?: boolean; // Kill any running MT5 instance first (default: true)
|
||||
timeout?: number; // Max wait time per week in seconds (default: 900)
|
||||
gui?: boolean; // Enable MT5 visualization window (default: false)
|
||||
startup_delay_secs?: number; // Seconds to wait for MT5 initialization (default: 10)
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: true;
|
||||
message: string; // "Rolling backtest launched with N weeks. Use get_backtest_status to poll for completion."
|
||||
report_id: string; // "ROLLING_MyEA_2026.06.24_2026.07.01"
|
||||
report_dir: string; // Full path to report directory
|
||||
expert: string;
|
||||
weeks: Array<{
|
||||
label: string; // "Week 1", "Week 2", etc.
|
||||
from_date: string; // "2026.06.24"
|
||||
to_date: string; // "2026.06.30"
|
||||
}>;
|
||||
poll_hint: string; // "Call get_backtest_status with report_dir to check progress"
|
||||
}
|
||||
```
|
||||
|
||||
### Status polling
|
||||
|
||||
After launch, poll with `get_backtest_status(report_dir=<dir>)` to track progress. The rolling backtest runs all weeks in a background task — each week is a full backtest pipeline (clean → launch → poll → extract → analyze).
|
||||
|
||||
Once complete, the report directory contains:
|
||||
- `rolling_results.json` — full summary with per-week metrics and totals
|
||||
- `progress.log` — current week being processed
|
||||
- `weeks.json` — the weekly schedule
|
||||
|
||||
### Example
|
||||
|
||||
```json
|
||||
// Input
|
||||
{
|
||||
"expert": "MyEA",
|
||||
"symbol": "XAUUSD",
|
||||
"weeks": 4,
|
||||
"deposit": 10000
|
||||
}
|
||||
|
||||
// Output
|
||||
{
|
||||
"success": true,
|
||||
"message": "Rolling backtest launched with 4 weeks. Use get_backtest_status to poll for completion.",
|
||||
"report_id": "ROLLING_MyEA_2026.06.03_2026.07.01",
|
||||
"report_dir": "reports/ROLLING_MyEA_2026.06.03_2026.07.01",
|
||||
"expert": "MyEA",
|
||||
"weeks": [
|
||||
{ "label": "Jun 03 - Jun 07", "from_date": "2026.06.03", "to_date": "2026.06.07" },
|
||||
{ "label": "Jun 10 - Jun 14", "from_date": "2026.06.10", "to_date": "2026.06.14" },
|
||||
{ "label": "Jun 17 - Jun 21", "from_date": "2026.06.17", "to_date": "2026.06.21" },
|
||||
{ "label": "Jun 24 - Jun 28", "from_date": "2026.06.24", "to_date": "2026.06.28" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Rolling results format (rolling_results.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"weeks_run": 4,
|
||||
"summary": {
|
||||
"total_net_profit": 12450.50,
|
||||
"max_drawdown_pct": 8.5,
|
||||
"total_trades": 342
|
||||
},
|
||||
"weekly_results": [
|
||||
{
|
||||
"label": "Jun 03 - Jun 07",
|
||||
"from_date": "2026.06.03",
|
||||
"to_date": "2026.06.07",
|
||||
"success": true,
|
||||
"net_profit": 3200.00,
|
||||
"max_dd_pct": 3.2,
|
||||
"total_trades": 85,
|
||||
"profit_factor": 1.45,
|
||||
"report_dir": "reports/20260701_120000_MyEA_XAUUSD_M5"
|
||||
},
|
||||
{
|
||||
"label": "Jun 10 - Jun 14",
|
||||
"from_date": "2026.06.10",
|
||||
"to_date": "2026.06.14",
|
||||
"success": true,
|
||||
"net_profit": -450.00,
|
||||
"max_dd_pct": 8.5,
|
||||
"total_trades": 92,
|
||||
"profit_factor": 0.92,
|
||||
"report_dir": "reports/20260701_123000_MyEA_XAUUSD_M5"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `get_backtest_status`
|
||||
|
||||
Check progress of a running backtest pipeline launched via `launch_backtest`.
|
||||
@@ -259,7 +398,7 @@ Launch genetic parameter optimization as a detached background process.
|
||||
|
||||
**Important:** This tool returns immediately. MT5 runs for 2-6 hours. The AI agent must NOT poll for results — the user monitors MT5 and signals when done. Call `get_optimization_results` only after user confirmation.
|
||||
|
||||
**Always uses model 0.** Model 1 (1-min OHLC) overfits grid/martingale EAs because intra-bar price movement is not simulated. Parameters that look optimal on model 1 fail on model 0 verification — this is a known trap.
|
||||
**Uses Model=1 (1-min OHLC) for faster optimization.** Use a separate `run_backtest` with `model=0` to verify top optimization results — Model 1 optimization parameters may overfit grid/martingale EAs because intra-bar price movement is not simulated. The `get_optimization_status` tool now auto-parses results when optimization completes, returning top passes, best PF, and best profit.
|
||||
|
||||
### Input schema
|
||||
|
||||
@@ -271,6 +410,7 @@ Launch genetic parameter optimization as a detached background process.
|
||||
to: string; // "YYYY-MM-DD"
|
||||
symbol?: string; // Default from config
|
||||
deposit?: number; // Default from config
|
||||
max_passes?: number; // Cap on genetic optimization passes (e.g. 5000 to run fewer)
|
||||
currency?: string; // Default: "USD"
|
||||
leverage?: number; // Default: 500
|
||||
log_file?: string; // Where to write nohup output (default: /tmp/opt_<timestamp>.log)
|
||||
@@ -441,14 +581,19 @@ Check the live state of a background optimization job (started by `run_optimizat
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
status: "running" | "stopped" | "completed";
|
||||
job_id: string;
|
||||
alive: boolean; // True if the optimization process is still running
|
||||
pid: number;
|
||||
started_at: string; // ISO timestamp
|
||||
elapsed_seconds: number;
|
||||
report_found: boolean; // True if MT5 has written the result file
|
||||
report_path: string | null;
|
||||
log_tail: string[]; // Last 10 lines of the nohup log
|
||||
expert?: string;
|
||||
symbol?: string;
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
started_at?: string;
|
||||
// Present when status is "completed":
|
||||
total_passes?: number;
|
||||
top_10?: Array<{ pass: number; profit: number; profit_factor: number; drawdown_pct: number; }>;
|
||||
best_pf?: { /* best pass by profit factor */ };
|
||||
best_profit?: { /* best pass by profit */ };
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+18
-166
@@ -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 90 tools.
|
||||
|
||||
+14
-95
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
@@ -7,13 +7,13 @@
|
||||
"url": "https://github.com/masdevid/mt5-quant",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "1.31.5",
|
||||
"version": "1.34.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "mcpb",
|
||||
"version": "1.31.5",
|
||||
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.31.5/mcp-mt5-quant-macos-arm64.tar.gz",
|
||||
"fileSha256": "97e05d3bc97270866d5736660bd19f071fd0fabbc474ba481825d69ec2b4bdf5",
|
||||
"version": "1.34.0",
|
||||
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.34.0/mcp-mt5-quant-macos-arm64.tar.gz",
|
||||
"fileSha256": "a8228ff8b547cae86b9cd3ce86704cc3f42b8fe25d034e98b5c0f5f599add50a",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
|
||||
+82
-45
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,6 +4,7 @@ use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use crate::{models::Config as ModelsConfig, tools::ToolHandler, McpError, McpRequest, McpResponse};
|
||||
|
||||
#[allow(dead_code)]
|
||||
type NotificationCallback = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
|
||||
|
||||
/// Auto-verify result stored after first initialization
|
||||
@@ -59,6 +60,7 @@ impl McpServer {
|
||||
*handler_guard = Some(new_handler);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_notification_sender(&self) -> Option<mpsc::UnboundedSender<Notification>> {
|
||||
let guard = self.notification_tx.lock().await;
|
||||
guard.clone()
|
||||
|
||||
+96
-35
@@ -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))
|
||||
|
||||
@@ -14,8 +14,6 @@ pub struct Report {
|
||||
pub from_date: String,
|
||||
pub to_date: String,
|
||||
pub metrics_file: PathBuf,
|
||||
pub deals_csv: PathBuf,
|
||||
pub deals_json: PathBuf,
|
||||
pub analysis_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -42,8 +40,6 @@ pub struct PipelineMetadata {
|
||||
pub struct FilePaths {
|
||||
pub metrics: String,
|
||||
pub analysis: String,
|
||||
pub deals_csv: String,
|
||||
pub deals_json: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -83,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 {
|
||||
@@ -105,6 +104,7 @@ impl BacktestJob {
|
||||
mt5_pid: None,
|
||||
expected_report_path,
|
||||
timeout_seconds,
|
||||
status: Some("running".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+298
-99
@@ -5,6 +5,28 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
use crate::models::Config;
|
||||
use crate::optimization::OptimizationParser;
|
||||
|
||||
/// 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,
|
||||
@@ -13,9 +35,9 @@ pub struct OptimizationParams {
|
||||
pub from_date: String,
|
||||
pub to_date: String,
|
||||
pub deposit: u32,
|
||||
pub model: u8,
|
||||
pub leverage: u32,
|
||||
pub currency: String,
|
||||
pub max_passes: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for OptimizationParams {
|
||||
@@ -27,9 +49,9 @@ impl Default for OptimizationParams {
|
||||
from_date: String::new(),
|
||||
to_date: String::new(),
|
||||
deposit: 10000,
|
||||
model: 0,
|
||||
leverage: 500,
|
||||
currency: "USD".to_string(),
|
||||
max_passes: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,13 +94,20 @@ impl OptimizationRunner {
|
||||
return Err(anyhow!("Set file not found: {}", params.set_file));
|
||||
}
|
||||
|
||||
// Kill any existing MT5/agent processes to avoid stale zombies
|
||||
for pat in &["terminal64\\.exe", "metatester64\\.exe"] {
|
||||
let _ = Command::new("pkill").args(["-KILL", "-f", pat]).output();
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||
|
||||
// Generate job ID and log file
|
||||
let timestamp = Utc::now().format("%Y%m%d_%H%M%S").to_string();
|
||||
let job_id = format!("opt_{}", timestamp);
|
||||
let log_file = PathBuf::from(format!("/tmp/mt5opt_{}.log", timestamp));
|
||||
|
||||
// Count combinations
|
||||
let combinations = self.count_combinations(¶ms.set_file)?;
|
||||
let combinations = self.count_combinations(¶ms.set_file)
|
||||
.map_err(|e| anyhow!("count_combinations failed: {}", e))?;
|
||||
|
||||
// Get paths
|
||||
let mt5_dir = self.config.terminal_dir.as_ref()
|
||||
@@ -89,50 +118,159 @@ 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(¶ms.set_file, &dst_set_file)?;
|
||||
self.write_utf16le_set(¶ms.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(¶ms.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 set_param = {
|
||||
let base = if !params.set_file.is_empty() && params.set_file != format!("{}.set", params.expert) {
|
||||
// Extract just the filename from the set file path
|
||||
std::path::Path::new(¶ms.set_file).file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(&format!("{}.set", params.expert))
|
||||
.to_string()
|
||||
} else {
|
||||
format!("{}.set", params.expert)
|
||||
};
|
||||
base
|
||||
};
|
||||
let mut tester_section = format!(
|
||||
"[Tester]\n\
|
||||
Expert={}\n\
|
||||
ExpertParameters={}\n\
|
||||
Symbol={}\n\
|
||||
Period=M1\n\
|
||||
Model=1\n\
|
||||
FromDate={}\n\
|
||||
ToDate={}\n\
|
||||
ForwardMode=0\n\
|
||||
Deposit={}\n\
|
||||
Currency={}\n\
|
||||
ProfitInPips=0\n\
|
||||
Leverage={}\n\
|
||||
Execution=10\n\
|
||||
Optimization=2\n\
|
||||
Visual=0\n\
|
||||
Report=..\\..\\mt5mcp_opt_report.htm\n\
|
||||
ReplaceReport=1\n\
|
||||
ShutdownTerminal=1",
|
||||
expert_path, set_param, params.symbol,
|
||||
params.from_date, params.to_date, params.deposit, params.currency, params.leverage,
|
||||
);
|
||||
if let Some(mp) = params.max_passes {
|
||||
tester_section.push_str(&format!("\nMaxPass={}", mp));
|
||||
}
|
||||
let updated_ini = Self::patch_ini_section(&mt5_ini_text, "Tester", &tester_section);
|
||||
// Strip any stale [Agents] sections from previous runs (no local agent processes)
|
||||
let cleaned = Self::strip_ini_section(&updated_ini, "Agents");
|
||||
let final_ini = cleaned.trim_end().to_string();
|
||||
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={}\n", set_param));
|
||||
opt_ini.push_str(&format!("Symbol={}\n", params.symbol));
|
||||
opt_ini.push_str("Period=M1\n");
|
||||
opt_ini.push_str("Model=1\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=..\\..\\mt5mcp_opt_report.htm\n");
|
||||
opt_ini.push_str("ReplaceReport=1\n");
|
||||
opt_ini.push_str("ShutdownTerminal=1\n");
|
||||
if let Some(mp) = params.max_passes {
|
||||
opt_ini.push_str(&format!("MaxPass={}\n", mp));
|
||||
}
|
||||
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 +288,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 +317,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 +343,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())
|
||||
}
|
||||
@@ -257,6 +373,7 @@ ShutdownTerminal=1
|
||||
|
||||
let meta_path = jobs_dir.join(format!("{}.json", job_id));
|
||||
let started_at = Utc::now().to_rfc3339();
|
||||
let report_path = wine_prefix.join("drive_c").join("mt5mcp_opt_report");
|
||||
|
||||
let metadata = serde_json::json!({
|
||||
"job_id": job_id,
|
||||
@@ -269,6 +386,7 @@ ShutdownTerminal=1
|
||||
"combinations": combinations,
|
||||
"log_file": log_file.to_string_lossy(),
|
||||
"wine_prefix": wine_prefix.to_string_lossy(),
|
||||
"report_path": report_path.to_string_lossy(),
|
||||
"started_at": started_at,
|
||||
});
|
||||
|
||||
@@ -276,6 +394,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));
|
||||
@@ -289,37 +474,51 @@ ShutdownTerminal=1
|
||||
|
||||
let meta: serde_json::Value = serde_json::from_str(&fs::read_to_string(&meta_path)?)?;
|
||||
let pid = meta.get("pid").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
|
||||
|
||||
// Check if process is still running
|
||||
let is_running = self.is_process_running(pid);
|
||||
|
||||
// Check for completion marker in log
|
||||
let log_file = meta.get("log_file").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let is_complete = if !log_file.is_empty() && Path::new(log_file).exists() {
|
||||
fs::read_to_string(log_file)
|
||||
.map(|content| content.contains("Optimization complete"))
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let status = if is_complete {
|
||||
"completed"
|
||||
} else if is_running {
|
||||
"running"
|
||||
} else {
|
||||
"stopped"
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"status": status,
|
||||
let mut result = serde_json::json!({
|
||||
"status": if is_running { "running" } else { "stopped" },
|
||||
"job_id": job_id,
|
||||
"pid": pid,
|
||||
"expert": meta.get("expert"),
|
||||
"symbol": meta.get("symbol"),
|
||||
"from_date": meta.get("from_date"),
|
||||
"to_date": meta.get("to_date"),
|
||||
"started_at": meta.get("started_at"),
|
||||
"log_file": log_file,
|
||||
}))
|
||||
});
|
||||
|
||||
// If not running, try to parse the optimization report
|
||||
if !is_running {
|
||||
let parser = OptimizationParser::new();
|
||||
match parser.parse_job(job_id) {
|
||||
Ok(passes) if !passes.is_empty() => {
|
||||
let mut sorted_by_pf = passes.clone();
|
||||
sorted_by_pf.sort_by(|a, b| b.profit_factor.partial_cmp(&a.profit_factor).unwrap());
|
||||
let top10: Vec<_> = sorted_by_pf.into_iter().take(10).collect();
|
||||
|
||||
let best_pf = parser.find_best_pass(&passes, "profit_factor");
|
||||
let best_profit = parser.find_best_pass(&passes, "profit");
|
||||
|
||||
let m = result.as_object_mut()
|
||||
.ok_or_else(|| anyhow!("result is not object"))?;
|
||||
m.insert("status".into(), serde_json::Value::String("completed".into()));
|
||||
m.insert("total_passes".into(), serde_json::json!(passes.len()));
|
||||
m.insert("top_10".into(), serde_json::to_value(&top10).unwrap_or_default());
|
||||
m.insert("best_pf".into(), serde_json::to_value(best_pf).unwrap_or_default());
|
||||
m.insert("best_profit".into(), serde_json::to_value(best_profit).unwrap_or_default());
|
||||
}
|
||||
_ => {
|
||||
let m = result.as_object_mut()
|
||||
.ok_or_else(|| anyhow!("result is not object"))?;
|
||||
m.insert("status".into(), serde_json::Value::String("stopped".into()));
|
||||
m.insert("message".into(), serde_json::Value::String(
|
||||
"Optimization stopped but no report found — may have crashed or was killed early".into()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn is_process_running(&self, pid: u32) -> bool {
|
||||
|
||||
@@ -31,6 +31,23 @@ impl OptimizationParser {
|
||||
}
|
||||
|
||||
let meta: serde_json::Value = serde_json::from_str(&fs::read_to_string(&meta_path)?)?;
|
||||
|
||||
// Check if report_path is stored in metadata
|
||||
if let Some(report_base) = meta.get("report_path").and_then(|v| v.as_str()) {
|
||||
let base_path = Path::new(report_base);
|
||||
for ext in &[".htm", ".htm.xml", ".html"] {
|
||||
let candidate = base_path.with_extension(ext.trim_start_matches('.'));
|
||||
if candidate.exists() {
|
||||
return self.parse_file(&candidate);
|
||||
}
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"Optimization report not found at {}.*. Is MT5 optimization still running?",
|
||||
base_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Fallback: derive from wine_prefix (legacy)
|
||||
let wine_prefix = meta.get("wine_prefix")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("wine_prefix not in job metadata"))?;
|
||||
@@ -169,13 +186,13 @@ impl OptimizationParser {
|
||||
|
||||
// Find all rows in Worksheet/Table
|
||||
for node in doc.descendants() {
|
||||
if node.has_tag_name(("http://schemas.microsoft.com/office/excel/2003/xml", "Row")) ||
|
||||
if node.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Row")) ||
|
||||
node.has_tag_name("Row") {
|
||||
let cells: Vec<String> = node.children()
|
||||
.filter(|n: &roxmltree::Node<'_, '_>| {
|
||||
n.has_tag_name(("http://schemas.microsoft.com/office/excel/2003/xml", "Cell")) ||
|
||||
n.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Cell")) ||
|
||||
n.has_tag_name("Cell") ||
|
||||
n.has_tag_name(("http://schemas.microsoft.com/office/excel/2003/xml", "Data")) ||
|
||||
n.has_tag_name(("urn:schemas-microsoft-com:office:spreadsheet", "Data")) ||
|
||||
n.has_tag_name("Data")
|
||||
})
|
||||
.map(|n| n.text().unwrap_or("").trim().to_string().replace(',', ""))
|
||||
|
||||
+611
-63
@@ -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(¶ms, &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(¶ms, &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())
|
||||
@@ -258,6 +262,28 @@ impl BacktestPipeline {
|
||||
let timeout_secs = params.timeout;
|
||||
let report_id_clone = report_id.clone();
|
||||
let notification_callback = self.notification_callback.clone();
|
||||
let config_clone = self.config.clone();
|
||||
let params_clone = BacktestParams {
|
||||
expert: params.expert.clone(),
|
||||
symbol: params.symbol.clone(),
|
||||
from_date: params.from_date.clone(),
|
||||
to_date: params.to_date.clone(),
|
||||
timeframe: params.timeframe.clone(),
|
||||
deposit: params.deposit,
|
||||
model: params.model,
|
||||
leverage: params.leverage,
|
||||
set_file: params.set_file.clone(),
|
||||
skip_compile: params.skip_compile,
|
||||
skip_clean: params.skip_clean,
|
||||
skip_analyze: params.skip_analyze,
|
||||
deep_analyze: params.deep_analyze,
|
||||
shutdown: params.shutdown,
|
||||
kill_existing: params.kill_existing,
|
||||
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(
|
||||
report_dir_clone,
|
||||
@@ -265,12 +291,81 @@ impl BacktestPipeline {
|
||||
timeout_secs,
|
||||
report_id_clone,
|
||||
notification_callback,
|
||||
config_clone,
|
||||
params_clone,
|
||||
).await;
|
||||
});
|
||||
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// Extract deals from a completed report and store them in the DB.
|
||||
/// Returns true if extraction and DB registration succeeded.
|
||||
async fn extract_and_store(
|
||||
report_path: &Path,
|
||||
report_dir: &Path,
|
||||
report_id: &str,
|
||||
config: &Config,
|
||||
params: &BacktestParams,
|
||||
) -> bool {
|
||||
let extractor = ReportExtractor::new();
|
||||
let start_time = chrono::Utc::now();
|
||||
match extractor.extract(
|
||||
&report_path.to_string_lossy(),
|
||||
&report_dir.to_string_lossy(),
|
||||
) {
|
||||
Ok(extraction) => {
|
||||
let duration = (chrono::Utc::now() - start_time).num_seconds();
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
if db.init().is_err() {
|
||||
tracing::warn!("launch_backtest: failed to init DB for {}", report_id);
|
||||
return false;
|
||||
}
|
||||
let entry = 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(extraction.metrics.net_profit),
|
||||
profit_factor: Some(extraction.metrics.profit_factor),
|
||||
max_dd_pct: Some(extraction.metrics.max_dd_pct),
|
||||
sharpe_ratio: Some(extraction.metrics.sharpe_ratio),
|
||||
total_trades: Some(extraction.metrics.total_trades as i64),
|
||||
win_rate_pct: Some(extraction.metrics.win_rate_pct),
|
||||
recovery_factor: Some(extraction.metrics.recovery_factor),
|
||||
deposit: Some(params.deposit as f64),
|
||||
currency: config.backtest_currency.clone(),
|
||||
leverage: Some(params.leverage as i64),
|
||||
duration_seconds: Some(duration),
|
||||
tags: Vec::new(),
|
||||
notes: None,
|
||||
verdict: None,
|
||||
};
|
||||
if let Err(e) = db.insert(&entry) {
|
||||
tracing::warn!("launch_backtest: failed to register report in DB: {}", e);
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = db.insert_deals(report_id, &extraction.deals) {
|
||||
tracing::warn!("launch_backtest: failed to store deals in DB: {}", e);
|
||||
}
|
||||
tracing::info!("launch_backtest: extracted {} deals for {}", extraction.deals.len(), report_id);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("launch_backtest: extraction failed for {}: {}", report_id, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task to monitor backtest completion and update status file.
|
||||
async fn monitor_backtest_completion(
|
||||
report_dir: PathBuf,
|
||||
@@ -278,20 +373,54 @@ impl BacktestPipeline {
|
||||
timeout_secs: u64,
|
||||
report_id: String,
|
||||
notification_callback: Option<NotificationCallback>,
|
||||
config: Config,
|
||||
params: BacktestParams,
|
||||
) {
|
||||
let start = tokio::time::Instant::now();
|
||||
let deadline = start + Duration::from_secs(timeout_secs);
|
||||
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, ¶ms).await;
|
||||
if extracted {
|
||||
let _ = fs::remove_file(&candidate);
|
||||
} 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!({
|
||||
@@ -304,14 +433,142 @@ 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, ¶ms).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, ¶ms).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
|
||||
if let Some(path) = Self::find_newest_report(expected_report.parent().unwrap(), poll_start) {
|
||||
tracing::info!("Backtest {} completed: found fallback report {}", report_id, path.display());
|
||||
// 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 => {
|
||||
tracing::error!("Backtest {}: expected_report path has no parent", report_id);
|
||||
Self::update_job_status(&report_dir, "failed", None).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
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, ¶ms).await;
|
||||
if extracted {
|
||||
let _ = fs::remove_file(&path);
|
||||
} else {
|
||||
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 {
|
||||
callback("backtest_completed", json!({
|
||||
@@ -320,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, ¶ms).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, ¶ms).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!({
|
||||
@@ -351,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");
|
||||
@@ -594,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();
|
||||
@@ -647,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!(
|
||||
@@ -711,12 +1243,12 @@ impl BacktestPipeline {
|
||||
};
|
||||
|
||||
let set_file_line = params.set_file.as_ref()
|
||||
.map(|p| format!("ExpertParameters={}\n", p))
|
||||
.map(|p| format!("ExpertParameters={}\n", Self::ini_safe(p)))
|
||||
.unwrap_or_default();
|
||||
|
||||
let updates: &[(&str, String)] = &[
|
||||
("Expert", expert_path),
|
||||
("Symbol", params.symbol.clone()),
|
||||
("Expert", Self::ini_safe(&expert_path)),
|
||||
("Symbol", Self::ini_safe(¶ms.symbol)),
|
||||
("Period", period.to_string()),
|
||||
("DateRange", "3".into()),
|
||||
("DateFrom", from_ts.to_string()),
|
||||
@@ -743,6 +1275,11 @@ impl BacktestPipeline {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Strip CR/LF from a user-supplied INI value to prevent newline injection.
|
||||
fn ini_safe(value: &str) -> String {
|
||||
value.replace(['\n', '\r'], "")
|
||||
}
|
||||
|
||||
fn patch_ini_section(text: &str, section: &str, updates: &[(&str, String)]) -> String {
|
||||
let section_header = format!("[{}]", section);
|
||||
let mut result = String::with_capacity(text.len() + 256);
|
||||
@@ -846,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");
|
||||
@@ -913,9 +1443,9 @@ impl BacktestPipeline {
|
||||
|
||||
ini.push_str("[Tester]\n");
|
||||
// Expert path is relative to MQL5/Experts/ in the /config: format (no "Experts\" prefix).
|
||||
ini.push_str(&format!("Expert={}\n", self.resolve_backtest_ini_expert_path(¶ms.expert)));
|
||||
ini.push_str(&format!("Symbol={}\n", params.symbol));
|
||||
ini.push_str(&format!("Period={}\n", params.timeframe));
|
||||
ini.push_str(&format!("Expert={}\n", Self::ini_safe(&self.resolve_backtest_ini_expert_path(¶ms.expert))));
|
||||
ini.push_str(&format!("Symbol={}\n", Self::ini_safe(¶ms.symbol)));
|
||||
ini.push_str(&format!("Period={}\n", Self::ini_safe(¶ms.timeframe)));
|
||||
ini.push_str("Optimization=0\n");
|
||||
ini.push_str(&format!("Model={}\n", params.model));
|
||||
ini.push_str(&format!("FromDate={}\n", params.from_date));
|
||||
@@ -932,7 +1462,7 @@ impl BacktestPipeline {
|
||||
ini.push_str(&format!("ShutdownTerminal={}\n", if params.shutdown { "1" } else { "0" }));
|
||||
|
||||
if let Some(set_file) = ¶ms.set_file {
|
||||
ini.push_str(&format!("ExpertParameters={}\n", set_file));
|
||||
ini.push_str(&format!("ExpertParameters={}\n", Self::ini_safe(set_file)));
|
||||
}
|
||||
|
||||
Ok(ini)
|
||||
@@ -955,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(())
|
||||
}
|
||||
@@ -1047,8 +1597,6 @@ impl BacktestPipeline {
|
||||
files: FilePaths {
|
||||
metrics: report_dir.join("metrics.json").to_string_lossy().to_string(),
|
||||
analysis: report_dir.join("analysis.json").to_string_lossy().to_string(),
|
||||
deals_csv: report_dir.join("deals.csv").to_string_lossy().to_string(),
|
||||
deals_json: report_dir.join("deals.json").to_string_lossy().to_string(),
|
||||
},
|
||||
no_trades,
|
||||
};
|
||||
|
||||
+33
-22
@@ -564,26 +564,39 @@ impl ReportDb {
|
||||
/// Search reports by tags (at least one tag must match)
|
||||
pub fn search_by_tags(&self, tags: &[String], limit: usize) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
let base_sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE 1=1"
|
||||
.to_string();
|
||||
FROM reports WHERE 1=1";
|
||||
|
||||
// Build OR conditions for tags - use JSON1 extension for tag matching
|
||||
if !tags.is_empty() {
|
||||
let tag_conditions: Vec<String> = tags.iter()
|
||||
.map(|tag| format!("tags LIKE '%{}%'", tag.replace("'", "''")))
|
||||
let (sql, params): (String, Vec<rusqlite::types::Value>) = if tags.is_empty() {
|
||||
(
|
||||
format!("{} ORDER BY created_at DESC LIMIT ?1", base_sql),
|
||||
vec![(limit as i64).into()],
|
||||
)
|
||||
} else {
|
||||
let placeholders = tags.iter().enumerate()
|
||||
.map(|(i, _)| format!("tags LIKE ?{}", i + 1))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" OR ");
|
||||
let sql = format!(
|
||||
"{} AND ({}) ORDER BY created_at DESC LIMIT ?{}",
|
||||
base_sql,
|
||||
placeholders,
|
||||
tags.len() + 1
|
||||
);
|
||||
let mut p: Vec<rusqlite::types::Value> = tags.iter()
|
||||
.map(|t| format!("%{}%", t).into())
|
||||
.collect();
|
||||
sql.push_str(&format!(" AND ({})", tag_conditions.join(" OR ")));
|
||||
}
|
||||
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
|
||||
p.push((limit as i64).into());
|
||||
(sql, p)
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
@@ -624,19 +637,18 @@ impl ReportDb {
|
||||
/// Search reports by notes text (case-insensitive LIKE)
|
||||
pub fn search_by_notes(&self, query: &str, limit: usize) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let pattern = format!("%{}%", query.replace("'", "''"));
|
||||
let pattern = format!("%{}%", query);
|
||||
let mut stmt = conn.prepare(
|
||||
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
"SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE notes LIKE '{}' ORDER BY created_at DESC LIMIT {}",
|
||||
pattern, limit)
|
||||
FROM reports WHERE notes LIKE ?1 ORDER BY created_at DESC LIMIT ?2"
|
||||
)?;
|
||||
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
.query_map(rusqlite::params![pattern, limit as i64], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
@@ -677,20 +689,19 @@ impl ReportDb {
|
||||
/// Find reports by set file (original or snapshot)
|
||||
pub fn search_by_set_file(&self, set_file: &str, limit: usize) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let pattern = format!("%{}%", set_file.replace("'", "''"));
|
||||
let pattern = format!("%{}%", set_file);
|
||||
let mut stmt = conn.prepare(
|
||||
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
"SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE (set_file_original LIKE '{}' OR set_snapshot_path LIKE '{}') \
|
||||
ORDER BY created_at DESC LIMIT {}",
|
||||
pattern, pattern, limit)
|
||||
FROM reports WHERE (set_file_original LIKE ?1 OR set_snapshot_path LIKE ?1) \
|
||||
ORDER BY created_at DESC LIMIT ?2"
|
||||
)?;
|
||||
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
.query_map(rusqlite::params![pattern, limit as i64], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
|
||||
@@ -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",
|
||||
@@ -129,6 +147,34 @@ pub fn tool_cache_status() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_run_rolling_backtest() -> Value {
|
||||
json!({
|
||||
"name": "run_rolling_backtest",
|
||||
"description": "Rolling backtest: run N consecutive weekly backtests sequentially and return aggregated results. Compiles once, then each week runs with skip_compile. Use this to test EA stability across multiple weeks.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert"],
|
||||
"properties": {
|
||||
"expert": { "type": "string", "description": "EA name without path or extension" },
|
||||
"symbol": { "type": "string", "description": "Trading symbol (default: from config or first available)" },
|
||||
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD (default: auto-calculate N weeks back)" },
|
||||
"to_date": { "type": "string", "description": "End date YYYY.MM.DD (default: auto-calculate to last Sunday)" },
|
||||
"timeframe": { "type": "string", "enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1"], "description": "Chart timeframe (default: M5)" },
|
||||
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" },
|
||||
"model": { "type": "integer", "enum": [0, 1, 2], "description": "Tick model: 0=Every tick, 1=OHLC, 2=Open prices" },
|
||||
"set_file": { "type": "string", "description": "Path to .set parameter file for EA inputs" },
|
||||
"weeks": { "type": "integer", "description": "Number of weekly backtests to run (default: 4)" },
|
||||
"skip_compile": { "type": "boolean", "description": "Skip initial compilation (default: false — compiles on first week)" },
|
||||
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest completes (default: true)" },
|
||||
"kill_existing": { "type": "boolean", "description": "Kill any running MT5 instance first (default: true)" },
|
||||
"timeout": { "type": "integer", "description": "Max wait time per week in seconds (default: 900)" },
|
||||
"gui": { "type": "boolean", "description": "Enable MT5 visualization window" },
|
||||
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_clean_cache() -> Value {
|
||||
json!({
|
||||
"name": "clean_cache",
|
||||
|
||||
@@ -18,6 +18,8 @@ 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_run_rolling_backtest(), // Rolling backtest: multiple sequential weeks
|
||||
backtest::tool_cache_status(),
|
||||
backtest::tool_clean_cache(),
|
||||
// Optimization
|
||||
|
||||
@@ -13,7 +13,8 @@ pub fn tool_run_optimization() -> Value {
|
||||
"symbol": { "type": "string", "description": "Trading symbol (default: XAUUSD)" },
|
||||
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD" },
|
||||
"to_date": { "type": "string", "description": "End date YYYY.MM.DD" },
|
||||
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" }
|
||||
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" },
|
||||
"max_passes": { "type": "integer", "description": "Cap on genetic optimization passes (MT5 default: ~10496 for 10 params). Use e.g. 5000 to run ~50% fewer passes." }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -289,7 +289,7 @@ pub fn tool_get_wine_prefix_info() -> Value {
|
||||
pub fn tool_get_backtest_crash_info() -> Value {
|
||||
json!({
|
||||
"name": "get_backtest_crash_info",
|
||||
"description": "Investigate backtest crashes/failures. Checks for incomplete markers, missing deals.csv, error logs. Can scan recent reports.",
|
||||
"description": "Investigate backtest crashes/failures. Checks for incomplete markers, missing metrics.json, DB deal count, error logs. Can scan recent reports.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+584
-80
@@ -1,8 +1,10 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Datelike;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tokio::time::Duration;
|
||||
use crate::models::Config;
|
||||
use crate::models::report::BacktestJob;
|
||||
use crate::pipeline::backtest::{BacktestParams, BacktestPipeline};
|
||||
@@ -74,66 +76,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 +195,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 +217,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 +258,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 +340,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 {
|
||||
@@ -312,6 +377,348 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_run_rolling_backtest(config: &Config, args: &Value) -> Result<Value> {
|
||||
let expert = args.get("expert")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("expert is required"))?;
|
||||
|
||||
let weeks_count = args.get("weeks").and_then(|v| v.as_u64()).unwrap_or(4) as i64;
|
||||
if weeks_count < 1 || weeks_count > 52 {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": "weeks must be between 1 and 52".to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Calculate weekly date ranges
|
||||
let from_arg = args.get("from_date").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let to_arg = args.get("to_date").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
let weeks: Vec<(String, String, String)> = if !from_arg.is_empty() && !to_arg.is_empty() {
|
||||
let start = chrono::NaiveDate::parse_from_str(from_arg, "%Y.%m.%d")
|
||||
.map_err(|e| anyhow::anyhow!("Invalid from_date '{}': {}", from_arg, e))?;
|
||||
let end = chrono::NaiveDate::parse_from_str(to_arg, "%Y.%m.%d")
|
||||
.map_err(|e| anyhow::anyhow!("Invalid to_date '{}': {}", to_arg, e))?;
|
||||
if end <= start {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": "to_date must be after from_date".to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
let mut weeks = Vec::new();
|
||||
let mut current = start;
|
||||
let mut week_idx = 0;
|
||||
while current < end && weeks.len() < weeks_count as usize {
|
||||
week_idx += 1;
|
||||
let week_end = if current + chrono::Duration::days(7) >= end {
|
||||
end
|
||||
} else {
|
||||
let days_to_sun = 6 - current.weekday().num_days_from_monday();
|
||||
let week_end_raw = current + chrono::Duration::days(days_to_sun as i64);
|
||||
std::cmp::min(week_end_raw, end)
|
||||
};
|
||||
let label = format!("Week {}", week_idx);
|
||||
weeks.push((label, current.format("%Y.%m.%d").to_string(), week_end.format("%Y.%m.%d").to_string()));
|
||||
current = week_end + chrono::Duration::days(1);
|
||||
}
|
||||
weeks
|
||||
} else {
|
||||
let now = chrono::Utc::now().date_naive();
|
||||
let days_from_sun = now.weekday().num_days_from_sunday();
|
||||
let last_sun = now - chrono::Duration::days(days_from_sun as i64);
|
||||
let mut weeks = Vec::new();
|
||||
for i in 0..weeks_count {
|
||||
let i = weeks_count - 1 - i;
|
||||
let week_end = last_sun - chrono::Duration::days(i * 7);
|
||||
let week_start = week_end - chrono::Duration::days(6);
|
||||
let label = format!(
|
||||
"{} - {}",
|
||||
week_start.format("%b %d"),
|
||||
week_end.format("%b %d")
|
||||
);
|
||||
weeks.push((label, week_start.format("%Y.%m.%d").to_string(), week_end.format("%Y.%m.%d").to_string()));
|
||||
}
|
||||
weeks
|
||||
};
|
||||
|
||||
let symbol = args.get("symbol").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let timeframe = args.get("timeframe").and_then(|v| v.as_str()).unwrap_or("M5").to_string();
|
||||
let deposit = args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32;
|
||||
let model = args.get("model").and_then(|v| v.as_u64()).unwrap_or(0) as u8;
|
||||
let leverage: u32 = args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32;
|
||||
let set_file = args.get("set_file").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
let deep_analyze = args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let shutdown = args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let kill_existing = args.get("kill_existing").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let timeout = args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900);
|
||||
let gui = args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let startup_delay_secs = args.get("startup_delay_secs").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let skip_compile_first = args.get("skip_compile").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
// Create a rolling job report dir for status tracking
|
||||
let report_id = format!(
|
||||
"ROLLING_{}_{}_{}",
|
||||
expert,
|
||||
weeks.first().map(|w| &w.1).unwrap_or(&"unknown".to_string()),
|
||||
weeks.last().map(|w| &w.2).unwrap_or(&"unknown".to_string()),
|
||||
);
|
||||
let report_dir = config.reports_dir().join(&report_id);
|
||||
fs::create_dir_all(&report_dir)?;
|
||||
|
||||
let job_path = report_dir.join("job.json");
|
||||
let job = BacktestJob {
|
||||
report_id: report_id.clone(),
|
||||
report_dir: report_dir.to_string_lossy().to_string(),
|
||||
expert: expert.to_string(),
|
||||
symbol: symbol.clone(),
|
||||
timeframe: timeframe.clone(),
|
||||
mt5_pid: None,
|
||||
expected_report_path: String::new(),
|
||||
timeout_seconds: timeout * weeks.len() as u64,
|
||||
launched_at: chrono::Utc::now().to_rfc3339(),
|
||||
status: Some("launched".to_string()),
|
||||
};
|
||||
fs::write(&job_path, serde_json::to_string_pretty(&job)?)?;
|
||||
|
||||
// Save the weekly schedule for status polling
|
||||
let weeks_path = report_dir.join("weeks.json");
|
||||
fs::write(&weeks_path, serde_json::to_string_pretty(&json!({
|
||||
"weeks": weeks.iter().map(|(l, f, t)| json!({"label": l, "from_date": f, "to_date": t})).collect::<Vec<_>>()
|
||||
}))?)?;
|
||||
|
||||
// Spawn background task to run all weeks sequentially
|
||||
let config_clone = config.clone();
|
||||
let report_dir_clone = report_dir.clone();
|
||||
let expert_clone = expert.to_string();
|
||||
let symbol_clone = symbol;
|
||||
let timeframe_clone = timeframe;
|
||||
let set_file_clone = set_file;
|
||||
let weeks_clone = weeks.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = run_rolling_weeks_sequential(
|
||||
config_clone,
|
||||
report_dir_clone,
|
||||
expert_clone,
|
||||
symbol_clone,
|
||||
timeframe_clone,
|
||||
deposit,
|
||||
model,
|
||||
leverage,
|
||||
set_file_clone,
|
||||
deep_analyze,
|
||||
shutdown,
|
||||
kill_existing,
|
||||
timeout,
|
||||
gui,
|
||||
startup_delay_secs,
|
||||
skip_compile_first,
|
||||
weeks_clone,
|
||||
).await {
|
||||
tracing::error!("Rolling backtest failed: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"message": format!("Rolling backtest launched with {} weeks. Use get_backtest_status to poll for completion.", weeks.len()),
|
||||
"report_id": report_id,
|
||||
"report_dir": report_dir.to_string_lossy(),
|
||||
"expert": expert,
|
||||
"weeks": weeks.iter().map(|(l, f, t)| json!({"label": l, "from_date": f, "to_date": t})).collect::<Vec<_>>(),
|
||||
"poll_hint": "Call get_backtest_status with report_dir to check progress"
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn run_rolling_weeks_sequential(
|
||||
config: Config,
|
||||
report_dir: PathBuf,
|
||||
expert: String,
|
||||
symbol: String,
|
||||
timeframe: String,
|
||||
deposit: u32,
|
||||
model: u8,
|
||||
leverage: u32,
|
||||
set_file: Option<String>,
|
||||
deep_analyze: bool,
|
||||
_shutdown: bool,
|
||||
_kill_existing: bool,
|
||||
timeout: u64,
|
||||
gui: bool,
|
||||
startup_delay_secs: u64,
|
||||
skip_compile_first: bool,
|
||||
weeks: Vec<(String, String, String)>,
|
||||
) -> Result<()> {
|
||||
let pipeline = if cfg!(debug_assertions) {
|
||||
BacktestPipeline::new(config.clone())
|
||||
} else {
|
||||
BacktestPipeline::new(config.clone())
|
||||
};
|
||||
let progress_log = report_dir.join("progress.log");
|
||||
|
||||
// Clear stale results
|
||||
let results_path = report_dir.join("rolling_results.json");
|
||||
let _ = fs::remove_file(&results_path);
|
||||
|
||||
let mut week_results = Vec::new();
|
||||
let mut total_net_profit: f64 = 0.0;
|
||||
let mut max_drawdown: f64 = 0.0;
|
||||
let mut total_trades: u64 = 0;
|
||||
|
||||
// Kill MT5/wineserver before starting
|
||||
for pat in &["MetaTrader 5.app", "terminal64.exe", "wineserver"] {
|
||||
let _ = Command::new("pkill").args(["-KILL", "-f", pat]).output();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
for (idx, (label, from_date, to_date)) in weeks.iter().enumerate() {
|
||||
let skip_compile = if idx == 0 { skip_compile_first } else { true };
|
||||
|
||||
fs::write(&progress_log, format!("WEEK {}: {} ({} -> {})\n", idx + 1, label, from_date, to_date))
|
||||
.unwrap_or(());
|
||||
|
||||
tracing::info!("Rolling week {}/{}: {} ({} -> {})", idx + 1, weeks.len(), label, from_date, to_date);
|
||||
|
||||
// Clean slate before each week
|
||||
for pat in &["MetaTrader 5.app", "terminal64.exe", "wineserver"] {
|
||||
let _ = Command::new("pkill").args(["-KILL", "-f", pat]).output();
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// Fire-and-forget launch (handles journal fallback if HTML not produced)
|
||||
let params = BacktestParams {
|
||||
expert: expert.clone(),
|
||||
symbol: symbol.clone(),
|
||||
from_date: from_date.clone(),
|
||||
to_date: to_date.clone(),
|
||||
timeframe: timeframe.clone(),
|
||||
deposit,
|
||||
model,
|
||||
leverage,
|
||||
set_file: set_file.clone(),
|
||||
skip_compile,
|
||||
skip_clean: false,
|
||||
skip_analyze: false,
|
||||
deep_analyze,
|
||||
shutdown: true,
|
||||
kill_existing: false,
|
||||
timeout,
|
||||
gui,
|
||||
startup_delay_secs,
|
||||
inactivity_kill_secs: None,
|
||||
};
|
||||
|
||||
let job = match pipeline.launch_backtest(params).await {
|
||||
Ok(j) => j,
|
||||
Err(e) => {
|
||||
tracing::error!("Rolling week {}/{} launch failed: {}", idx + 1, weeks.len(), e);
|
||||
week_results.push(json!({
|
||||
"label": label, "from_date": from_date, "to_date": to_date,
|
||||
"success": false, "error": format!("launch failed: {}", e)
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Poll for completion (up to timeout)
|
||||
let week_report_dir = Path::new(&job.report_dir);
|
||||
let poll_start = std::time::Instant::now();
|
||||
let max_wait = Duration::from_secs(timeout);
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
// Check for metrics.json (written after extraction, including journal fallback)
|
||||
let metrics_path = week_report_dir.join("metrics.json");
|
||||
if metrics_path.exists() { break; }
|
||||
|
||||
let jp = week_report_dir.join("job.json");
|
||||
if let Ok(content) = fs::read_to_string(&jp) {
|
||||
if let Ok(j) = serde_json::from_str::<BacktestJob>(&content) {
|
||||
if let Some(ref s) = j.status {
|
||||
if s == "completed" || s == "completed_no_html" { break; }
|
||||
if s == "failed" || s == "timeout" || s == "timeout_inactive" {
|
||||
tracing::warn!("Rolling week {}/{} background status: {}", idx + 1, weeks.len(), s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if poll_start.elapsed() > max_wait {
|
||||
tracing::warn!("Rolling week {}/{} timed out after {}s", idx + 1, weeks.len(), timeout);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a moment for extraction to finish
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Read results
|
||||
let metrics_path = week_report_dir.join("metrics.json");
|
||||
if let Ok(content) = fs::read_to_string(&metrics_path) {
|
||||
if let Ok(metrics) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
let np = metrics.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let dd = metrics.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
let trades = metrics.get("total_trades").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let pf = metrics.get("profit_factor").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
|
||||
total_net_profit += np;
|
||||
if dd > max_drawdown { max_drawdown = dd; }
|
||||
total_trades += trades;
|
||||
|
||||
tracing::info!("Rolling week {}/{} done: profit={:.2}, dd={:.1}%", idx + 1, weeks.len(), np, dd);
|
||||
|
||||
week_results.push(json!({
|
||||
"label": label, "from_date": from_date, "to_date": to_date,
|
||||
"success": true, "net_profit": np, "max_dd_pct": dd,
|
||||
"total_trades": trades, "profit_factor": pf,
|
||||
"report_dir": job.report_dir
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// No metrics found
|
||||
week_results.push(json!({
|
||||
"label": label, "from_date": from_date, "to_date": to_date,
|
||||
"success": false, "error": "No metrics extracted"
|
||||
}));
|
||||
}
|
||||
|
||||
// Write final results
|
||||
let summary = json!({
|
||||
"success": true,
|
||||
"weeks_run": week_results.len(),
|
||||
"summary": {
|
||||
"total_net_profit": total_net_profit,
|
||||
"max_drawdown_pct": max_drawdown,
|
||||
"total_trades": total_trades,
|
||||
},
|
||||
"weekly_results": week_results
|
||||
});
|
||||
if let Ok(json_str) = serde_json::to_string_pretty(&summary) {
|
||||
let _ = fs::write(&results_path, &json_str);
|
||||
}
|
||||
|
||||
// Update job status
|
||||
let job_path = report_dir.join("job.json");
|
||||
if let Ok(content) = fs::read_to_string(&job_path) {
|
||||
if let Ok(mut job) = serde_json::from_str::<BacktestJob>(&content) {
|
||||
job.status = Some("completed".to_string());
|
||||
if let Ok(json_str) = serde_json::to_string_pretty(&job) {
|
||||
let _ = fs::write(&job_path, json_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(&progress_log, "DONE\n").unwrap_or(());
|
||||
tracing::info!("Rolling backtest completed: {} weeks, profit={:.2}, max_dd={:.1}%", week_results.len(), total_net_profit, max_drawdown);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -348,16 +755,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 deals_exists = report_path.join("deals.csv").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| {
|
||||
@@ -366,26 +784,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)
|
||||
};
|
||||
@@ -400,7 +824,6 @@ pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Resul
|
||||
"mt5_running": mt5_running,
|
||||
"report_found": report_found,
|
||||
"metrics_extracted": metrics_exists,
|
||||
"deals_extracted": deals_exists,
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
"message": message,
|
||||
"job": job.map(|j| {
|
||||
@@ -435,6 +858,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))
|
||||
|
||||
@@ -70,10 +70,12 @@ 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,
|
||||
"run_rolling_backtest" => backtest::handle_run_rolling_backtest(&self.config, args).await,
|
||||
"cache_status" => backtest::handle_cache_status(&self.config).await,
|
||||
"clean_cache" => backtest::handle_clean_cache(&self.config, args).await,
|
||||
|
||||
@@ -179,6 +181,7 @@ pub(crate) fn dir_size(path: &Path) -> u64 {
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn past_complete_month() -> (String, String) {
|
||||
let now = chrono::Utc::now();
|
||||
let today = chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use crate::models::Config;
|
||||
use crate::optimization::{OptimizationParams, OptimizationParser, OptimizationRunner};
|
||||
|
||||
@@ -27,9 +29,9 @@ 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(),
|
||||
max_passes: args.get("max_passes").and_then(|v| v.as_u64()).map(|v| v as u32),
|
||||
};
|
||||
|
||||
let runner = OptimizationRunner::new(config.clone());
|
||||
@@ -56,6 +58,30 @@ pub async fn handle_get_optimization_status(config: &Config, args: &Value) -> Re
|
||||
let runner = OptimizationRunner::new(config.clone());
|
||||
let status = runner.get_job_status(job_id)?;
|
||||
|
||||
// Store completion results back to metadata for persistence
|
||||
if status.get("status").and_then(|v| v.as_str()) == Some("completed") {
|
||||
let jobs_dir = Path::new(".mt5mcp_jobs");
|
||||
let meta_path = jobs_dir.join(format!("{}.json", job_id));
|
||||
if let Ok(meta_str) = fs::read_to_string(&meta_path) {
|
||||
if let Ok(mut meta) = serde_json::from_str::<serde_json::Value>(&meta_str) {
|
||||
if let Some(obj) = meta.as_object_mut() {
|
||||
obj.insert("status".into(), serde_json::Value::String("completed".into()));
|
||||
obj.insert("completed_at".into(), serde_json::Value::String(chrono::Utc::now().to_rfc3339()));
|
||||
if let Some(top) = status.get("top_10") {
|
||||
obj.insert("top_10".into(), top.clone());
|
||||
}
|
||||
if let Some(best) = status.get("best_pf") {
|
||||
obj.insert("best_pf".into(), best.clone());
|
||||
}
|
||||
if let Some(total) = status.get("total_passes") {
|
||||
obj.insert("total_passes".into(), total.clone());
|
||||
}
|
||||
let _ = fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": status.to_string() }],
|
||||
"isError": false
|
||||
@@ -66,7 +92,7 @@ pub async fn handle_get_optimization_results(_config: &Config, args: &Value) ->
|
||||
let job_id = args.get("job_id")
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
let file = args.get("file")
|
||||
let file = args.get("report_file")
|
||||
.and_then(|v| v.as_str());
|
||||
|
||||
let parser = OptimizationParser::new();
|
||||
@@ -80,7 +106,7 @@ pub async fn handle_get_optimization_results(_config: &Config, args: &Value) ->
|
||||
};
|
||||
|
||||
let sort_by = args.get("sort").and_then(|v| v.as_str()).unwrap_or("profit");
|
||||
let top_n = args.get("top").and_then(|v| v.as_u64()).unwrap_or(30) as usize;
|
||||
let top_n = args.get("top_n").and_then(|v| v.as_u64()).unwrap_or(30) as usize;
|
||||
|
||||
let best = parser.find_best_pass(&passes, sort_by);
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
+218
-84
@@ -2,7 +2,60 @@ 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.
|
||||
@@ -785,7 +838,6 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result<Valu
|
||||
|
||||
let path = Path::new(report_dir);
|
||||
let metrics_path = path.join("metrics.json");
|
||||
let _deals_path = path.join("deals.csv");
|
||||
|
||||
// Read metrics
|
||||
let metrics: Value = if metrics_path.exists() {
|
||||
@@ -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(),
|
||||
@@ -1640,21 +1762,33 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re
|
||||
}
|
||||
}
|
||||
|
||||
// Check if deals.csv is missing or empty
|
||||
let deals_csv = path.join("deals.csv");
|
||||
if !deals_csv.exists() {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "missing_deals",
|
||||
"reason": "deals.csv not found - backtest likely failed",
|
||||
}));
|
||||
} else if let Ok(meta) = deals_csv.metadata() {
|
||||
if meta.len() < 100 {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "empty_deals",
|
||||
"reason": "deals.csv is nearly empty - no trades were made",
|
||||
}));
|
||||
// Check deal count in DB
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
if db.init().is_ok() {
|
||||
match db.get_by_report_dir(dir) {
|
||||
Ok(Some(entry)) => {
|
||||
let deal_count = db.get_deals(&entry.id)
|
||||
.map(|d| d.len())
|
||||
.unwrap_or(0);
|
||||
if deal_count == 0 {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "empty_deals",
|
||||
"reason": "No deals stored in DB - EA did not trade or extraction failed",
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
// Not in DB at all means extraction never completed
|
||||
let metrics_exists = path.join("metrics.json").exists();
|
||||
if !metrics_exists {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "missing_deals",
|
||||
"reason": "Report not found in DB and no metrics.json - backtest likely crashed before extraction",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1676,10 +1810,10 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if modified >= cutoff {
|
||||
// Check for failure indicators
|
||||
let has_deals = path.join("deals.csv").exists();
|
||||
let has_metrics = path.join("metrics.json").exists();
|
||||
let has_incomplete = path.join(".incomplete").exists();
|
||||
|
||||
if !has_deals || has_incomplete {
|
||||
|
||||
if !has_metrics || has_incomplete {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
@@ -1701,7 +1835,7 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re
|
||||
|
||||
if types.contains(&"missing_deals".to_string()) {
|
||||
result["common_patterns"].as_array_mut().unwrap().push(
|
||||
json!("Missing deals.csv suggests MT5 crashed during backtest")
|
||||
json!("Report not in DB and no metrics.json suggests MT5 crashed before extraction completed")
|
||||
);
|
||||
}
|
||||
if types.contains(&"incomplete".to_string()) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user