2 Commits

Author SHA1 Message Date
Devid HW 740f24784f release: v1.31.2
- Version bump 1.31.1 → 1.31.2
- server.json identifier URL updated (SHA256 set by CI after build)
- CHANGELOG.md updated
2026-04-22 07:01:47 +07:00
Devid HW d03f080b95 refactor: reduce handler boilerplate in analysis and experts modules
analysis.rs: extract required_str, ok_response, err_response, prepare_analysis,
deal_to_json, is_closed_trade helpers — 15 granular handlers reduced from
~14 lines each to 4 lines each (-293 lines total).

experts.rs: extract scan_mql_dir (replaces 6 identical WalkDir loops),
BUILTIN_INDICATORS const (deduplicates list/search handlers), and
copy_mql_to_project (merges copy_indicator/copy_script into one impl,
public handlers delegate in 1 line each) (-256 lines total).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:01:41 +07:00
38 changed files with 845 additions and 3398 deletions
+114
View File
@@ -0,0 +1,114 @@
---
description: Release a new version of mt5-quant — bump, changelog, tag, push
---
# /release — MT5-Quant Release Workflow
Automates the full release cycle: version bump → changelog → git tag → GitHub push → CI triggers build + MCP publish.
## Usage
```
/release [patch|minor|major|X.Y.Z]
```
Default is `patch` if no argument is given.
---
## Step 1 — Check current version and state
```bash
grep '^version' Cargo.toml | head -1
git status --short
git log --oneline -5
```
## Step 2 — Count tools (update docs if needed)
```bash
grep -r "pub fn tool_" src/tools/definitions/ | wc -l
```
If the count changed since the last release, update:
- `README.md` — "MCP Tools (N)" section header and description
- `docs/MCP_TOOLS.md` — "documents X of Y total tools" line
## Step 3 — Run the release script
```bash
bash scripts/release.sh patch
```
Replace `patch` with `minor`, `major`, or an explicit version like `1.33.0`.
The script will:
1. Bump `Cargo.toml` version
2. Update `server.json` version + download URL (SHA256 = TBD, set by CI)
3. Generate a `CHANGELOG.md` entry from `git log` since the last tag
4. Run `cargo check` to verify the build
5. Commit all changes
6. Create an annotated git tag
7. Push branch + tag → triggers GitHub Actions
## Step 4 — Monitor GitHub Actions
```bash
gh run list --limit 5
```
Or open: https://github.com/masdevid/mt5-quant/actions
The CI pipeline will:
- Build macOS arm64 + Linux x64 binaries
- Create the GitHub Release with both artifacts
- Build the MCP installable package
- Compute the real SHA256 and commit it back to `server.json`
- Attempt to publish to the MCP Registry
## Step 5 — Install updated binary locally
```bash
cp target/release/mt5-quant ~/.local/bin/mt5-quant
```
Or rebuild from the release tag:
```bash
cargo build --release && cp target/release/mt5-quant ~/.local/bin/mt5-quant
```
## Step 6 — Verify MCP registration
```bash
claude mcp list
~/.local/bin/mt5-quant --help 2>/dev/null || echo "binary works"
```
---
## Troubleshooting
**MCP publish failed in CI?**
```bash
# Download latest mcp-publisher
curl -L "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" | tar xz
./mcp-publisher login github
./mcp-publisher publish
```
**Need to re-run CI without a new tag?**
```bash
gh workflow run release.yml -f version=v1.32.0
```
**Rollback a bad release?**
```bash
git tag -d v1.32.0
git push origin :refs/tags/v1.32.0
# Then delete the GitHub Release via web UI or:
gh release delete v1.32.0
```
+4 -2
View File
@@ -20,12 +20,14 @@ config/baseline.json
config/backtest_history.json
# Claude Code (personal, not for repo)
/CLAUDE.md
.claude/
CLAUDE.md
# Windsurf (local workflows, not for repo)
.windsurf/
# GitHub Actions (local workflows, not for repo)
.github/workflows/
# macOS
.DS_Store
+113
View File
@@ -0,0 +1,113 @@
---
description: Release workflow — bump version, build, tag, push, publish MCP
tags: [release, publish, workflow]
---
# MT5-Quant Release Workflow
One-command release: `bash scripts/release.sh [patch|minor|major|X.Y.Z]`
---
## 0. Pre-release checklist
- [ ] Features implemented and tested
- [ ] Docs updated (README.md, docs/MCP_TOOLS.md) with correct tool count
- [ ] `cargo check` passes
Check tool count:
// turbo
```bash
grep -r "pub fn tool_" src/tools/definitions/ | wc -l
```
---
## 1. Run the release script
// turbo
```bash
bash scripts/release.sh patch
```
Accepts: `patch` · `minor` · `major` · `1.32.0` · `v1.32.0`
The script handles:
- Version bump in `Cargo.toml`
- `server.json` version + download URL update
- `CHANGELOG.md` entry generation from git log
- `cargo check` verification
- Release commit + annotated git tag
- `git push` → triggers GitHub Actions
---
## 2. Monitor CI
// turbo
```bash
gh run list --limit 3
```
CI pipeline stages:
1. **build-macos** / **build-linux** — parallel Rust builds with cache
2. **release** — creates GitHub Release with both binaries
3. **build-mcp-package** — packages binary + config + docs, computes SHA256
4. **update-server-json** — commits real SHA256 back to `server.json` on master
5. **mcp-publish** — publishes to MCP Registry via GitHub OIDC
6. **release-info** — prints registration commands for all MCP clients
---
## 3. Install locally
// turbo
```bash
cp target/release/mt5-quant ~/.local/bin/mt5-quant
```
---
## 4. Verify
// turbo
```bash
claude mcp list
```
---
## Manual MCP publish (if CI publish fails)
// turbo
```bash
# Get latest mcp-publisher
LATEST=$(curl -sf https://api.github.com/repos/modelcontextprotocol/registry/releases/latest | python3 -c "import sys,json; print(json.load(sys.stdin)['tag_name'])")
VERSION="${LATEST#v}"
curl -fsSL -o pub.tar.gz "https://github.com/modelcontextprotocol/registry/releases/download/${LATEST}/mcp-publisher_${VERSION}_$(uname -s | tr '[:upper:]' '[:lower:]')_amd64.tar.gz"
tar -xzf pub.tar.gz && chmod +x mcp-publisher
./mcp-publisher login github
./mcp-publisher publish
```
---
## Re-trigger CI without a new tag
// turbo
```bash
gh workflow run release.yml -f version=v1.32.0
```
---
## Rollback
```bash
# Remove tag locally and remotely
git tag -d v1.32.0
git push origin :refs/tags/v1.32.0
# Delete GitHub Release
gh release delete v1.32.0 --yes
```
-30
View File
@@ -1,35 +1,5 @@
# Changelog
## [1.31.5] — 2026-04-22
- feat: add check_update and update tools with background auto-check
- release: v1.31.4
- fix: update all scripts for correctness and consistency
- release: v1.31.3
- docs: clean up public repo — remove IDE files, fix stale refs
- release: v1.31.2
- refactor: reduce handler boilerplate in analysis and experts modules
- fix: registryType mcpbPackageType → mcpb
## [1.31.4] — 2026-04-22
- fix: update all scripts for correctness and consistency
- release: v1.31.3
- docs: clean up public repo — remove IDE files, fix stale refs
- release: v1.31.2
- refactor: reduce handler boilerplate in analysis and experts modules
- fix: registryType mcpbPackageType → mcpb
## [1.31.3] — 2026-04-22
- docs: clean up public repo — remove IDE files, fix stale refs
- release: v1.31.2
- refactor: reduce handler boilerplate in analysis and experts modules
- fix: registryType mcpbPackageType → mcpb
## [1.31.2] — 2026-04-22
- refactor: reduce handler boilerplate in analysis and experts modules
Generated
+1 -1
View File
@@ -481,7 +481,7 @@ dependencies = [
[[package]]
name = "mt5-quant"
version = "1.32.0"
version = "1.31.2"
dependencies = [
"anyhow",
"base64",
+2 -10
View File
@@ -1,17 +1,9 @@
[package]
name = "mt5-quant"
version = "1.32.0"
version = "1.31.2"
edition = "2021"
description = "MCP server for MT5 strategy development on macOS/Linux"
description = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP"
authors = ["masdevid <masdevid@example.com>"]
license = "MIT"
repository = "https://github.com/masdevid/mt5-quant"
readme = "README.md"
keywords = ["mt5", "mql5", "trading", "mcp", "backtest"]
categories = ["finance", "development-tools"]
homepage = "https://github.com/masdevid/mt5-quant"
documentation = "https://github.com/masdevid/mt5-quant"
maintenance = { status = "actively-developed" }
[[bin]]
name = "mt5-quant"
+17 -83
View File
@@ -1,6 +1,6 @@
# MT5-Quant
**MCP server for MT5 strategy development on macOS/Linux.** 89 tools to compile, backtest, analyze, optimize, debug crashes, and manage MQL5 Expert Advisors — no Windows required.
**MCP server for MT5 strategy development on macOS/Linux.** 87 tools to compile, backtest, analyze, optimize, debug crashes, and manage MQL5 Expert Advisors — no Windows required.
```
You: "Backtest MyEA Jan-Mar, what caused the February drawdown?"
@@ -13,57 +13,26 @@ Claude: [compile → clean → backtest → analyze 1,847 deals]
## Why MT5-Quant
**Focus:** Backtest organization, reporting, and analytics — capabilities MT5 itself doesn't provide.
| | MT5-Quant | Others |
|---|---|---|
| **Platform** | macOS/Linux native | Windows only |
| **Backtest pipeline** | ✅ Full (compile → run → analyze) | ✅ Via MT5 Python package |
| **Deal-level analytics** | ✅ 19 dimensions, DB-backed | ❌ |
| **Report organization** | ✅ SQLite (reports + deals) + search + history | ❌ |
| **MQL5 compilation** | ✅ Headless (MetaEditor via Wine, no GUI) | ⚠️ Via GUI or terminal |
| **Optimization** | ✅ Background + results parsing + .set generation | ⚠️ Terminal only, no parsing |
| **Crash debugging** | ✅ Wine/MT5 diagnostics | ❌ |
**Others** typically run on Windows using the [MetaTrader5 Python package](https://pypi.org/project/MetaTrader5/), providing full terminal operations. MT5-Quant fills the gap: **organizing backtest reports, extracting deal-level insights, and managing optimization workflows** — none of which MT5 or its Python API expose natively.
## Why Rust
**MT5-Quant is built entirely in Rust** — one static binary, zero dependencies, instant startup.
| | Rust | Python/Node |
|---|---|---|
| **Startup** | ~10ms | 200500ms |
| **Deploy** | Single binary | venv + pip/npm |
| **Memory** | <50MB, no GC pauses | Unpredictable spikes |
| **Safety** | Compile-time guaranteed | Runtime exceptions |
**Why this matters for trading:**
- **No garbage collection** — Process 100k+ deal rows without GC pauses during live analysis
- **Async via Tokio** — Handle multiple tool calls concurrently (poll status while streaming logs)
- **Cross-platform** — Same source compiles to native macOS arm64 and Linux x86_64
- **Type-safe pipelines** — MQL5 compilation, Wine path handling, SQLite queries: all checked at compile time
| | MT5-Quant | Other MT5 MCPs | QuantConnect |
|---|---|---|---|
| Backtest pipeline | ✅ Full | ❌ | Cloud only |
| Deal-level analytics | ✅ 15+ dims | ❌ | ❌ |
| MQL5 compilation | ✅ | ❌ | ❌ |
| macOS/Linux native | ✅ | Windows only | Cloud |
| Optimization | ✅ Background | ❌ | ✅ Paid |
| Crash debugging | ✅ Wine/MT5 diagnostics | ❌ | ❌ |
## Quick Install
### Option 1: Pre-built Binary (Recommended)
### 1. Download & Setup
```bash
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5.tar.gz
bash scripts/setup.sh
```
### Option 2: Cargo Install (if you have Rust)
```bash
cargo install mt5-quant
mt5-quant --help
```
Compiles from source. Takes 25 minutes. Requires Rust 1.70+.
### Register MCP Server
### 2. Register MCP Server
| Platform | Command / Config | Docs |
|----------|------------------|------|
@@ -71,7 +40,7 @@ Compiles from source. Takes 25 minutes. Requires Rust 1.70+.
| **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) |
| **Antigravity** | Agent Panel → ... → MCP Servers → Edit configuration | [ANTIGRAVITY.md →](docs/ANTIGRAVITY.md) |
> **Note:** Use absolute paths like `/Users/name/mt5-quant/mt5-quant` or `$(pwd)/mt5-quant`, not relative paths like `./mt5-quant`.
@@ -91,14 +60,14 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
| [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 |
| [ANTIGRAVITY.md](docs/ANTIGRAVITY.md) | Antigravity IDE setup |
| [CONFIG.md](docs/CONFIG.md) | Configuration reference |
| [TOOLS.md](docs/MCP_TOOLS.md) | All 89 tools documented |
| [TOOLS.md](docs/MCP_TOOLS.md) | All 87 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)
## MCP Tools (87)
### Core workflow
@@ -111,7 +80,6 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
| `get_backtest_status` | Poll running backtest status (MT5 running, report found, elapsed time) |
| `run_optimization` | Genetic optimization (background, returns immediately) |
| `get_optimization_results` | Parse optimization results after MT5 finishes |
| `list_jobs` | List all optimization jobs with status |
| `analyze_report` | Read `analysis.json` from any report directory |
| `compare_baseline` | Compare report vs baseline, return winner/loser verdict |
| `compile_ea` | Compile MQL5 EA via MetaEditor |
@@ -119,7 +87,6 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
| `list_indicators` | List all indicators in MQL5/Indicators directory |
| `list_scripts` | List all scripts in MQL5/Scripts directory |
| `healthcheck` | Quick server health check |
| `list_symbols` | List all available symbols in MT5 terminal |
### Granular Analytics (individual analysis)
@@ -176,7 +143,6 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
| `get_comparable_reports` | Find comparable reports (same EA/symbol/timeframe) |
| `tail_log` | Read last N lines of any log; `filter=errors` to see only failures |
| `prune_reports` | Delete old report directories, keep last N (skips `_opt` dirs) |
| `promote_to_baseline` | Write a history entry or report to `baseline.json` for compare_baseline |
### History & baseline
@@ -186,6 +152,7 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
| `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 |
| `annotate_history` | Attach verdict / notes / tags to any history entry |
| `promote_to_baseline` | Write a history entry or report to `baseline.json` for compare_baseline |
### Cache management
@@ -216,8 +183,6 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
| `validate_mt5_config` | Validate terminal.ini and tester configuration files |
| `get_wine_prefix_info` | Get Wine prefix details: Windows version, installed programs, registry |
| `get_backtest_crash_info` | Investigate backtest failures: incomplete markers, missing deals.csv, errors |
| `check_update` | Check if a newer version of MT5-Quant is available |
| `update` | Update MT5-Quant to latest release |
### Project Management
@@ -278,37 +243,6 @@ For crashes or unexplained failures during backtest/compile/optimization:
---
## Acknowledgements
MT5-Quant stands on the shoulders of exceptional open-source projects:
- **[Rust](https://www.rust-lang.org/)** — The language that makes zero-cost abstractions, memory safety, and fearless concurrency practical
- **[Tokio](https://tokio.rs/)** — The async runtime powering all concurrent operations
- **[Wine](https://www.winehq.org/)** — Making MT5 execution possible on macOS and Linux without Windows licensing
- **[MetaTrader 5](https://www.metatrader5.com/)** — MetaQuotes' trading platform (trademark of MetaQuotes Software Corp.)
- **[rusqlite](https://github.com/rusqlite/rusqlite)** — Ergonomic SQLite bindings for Rust
- **[serde](https://serde.rs/)** — The serialization framework making config and report handling painless
- **[scraper](https://github.com/causal-agent/scraper)** — HTML parsing for MT5 report extraction
- **[tempfile](https://github.com/Stebalien/temp-file)** — Secure temporary file handling
Special thanks to the Model Context Protocol (MCP) team at Anthropic for defining the standard that makes AI-powered development workflows possible.
## Disclaimer
**Not Financial Advice.** MT5-Quant is a development and analysis tool for algorithmic trading strategies. It does not provide investment advice, trading recommendations, or guarantee profitability. All backtest results are historical simulations and do not guarantee future performance.
**Use at Your Own Risk.** Trading financial instruments carries substantial risk of loss. The authors and contributors of MT5-Quant accept no liability for:
- Trading losses incurred using strategies developed or tested with this tool
- Data loss or corruption from backtest operations
- Bugs, errors, or incorrect analysis results
- System crashes, Wine compatibility issues, or MT5 failures
**Software Warranty.** This software is provided "as-is" without warranty of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, or non-infringement. See LICENSE for full terms.
**Third-Party Software.** MT5-Quant interacts with MetaTrader 5, Wine, and other third-party software. Users are responsible for complying with all applicable licenses and terms of service for these dependencies. MetaTrader is a trademark of MetaQuotes Software Corp. MT5-Quant is not affiliated with, endorsed by, or sponsored by MetaQuotes.
**Regulatory Compliance.** Users are responsible for ensuring their trading activities comply with applicable financial regulations in their jurisdiction. Automated trading may be restricted or require licensing in some regions.
## License
MIT
+145
View File
@@ -0,0 +1,145 @@
# Antigravity MCP Integration Setup
## Quick Setup
### Option 1: Download Prebuilt Binary (Recommended)
```bash
# macOS (Apple Silicon)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 2: Build from Source
```bash
cargo build --release
```
## Configure Antigravity
### Step 1: Open MCP Manager
1. Launch Antigravity
2. Look at the **right-side Agent Panel**
3. Click the **"..."** (More Options) menu at the top
4. Select **MCP Servers**
### Step 2: Access Configuration
1. Click **Manage MCP Servers**
2. Click **View raw config** or **Edit configuration**
3. This opens `mcp_config.json`
### Step 3: Add mt5-quant Configuration
Add to `mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/absolute/path/to/mt5-quant"
}
}
}
```
### Step 4: Reload and Verify
1. Save the file
2. **Restart Antigravity** (or reload the window)
3. Open the Agent chat and type: `What tools do you have access to?`
4. The agent should list MT5 tools like `verify_setup`, `run_backtest`, etc.
## Full Example Configuration
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/name/mt5-quant/target/release/mt5-quant"
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${env:GITHUB_TOKEN}"
}
}
}
}
```
## Environment Variables
Antigravity supports `${VAR_NAME}` syntax for environment variable substitution:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/name/mt5-quant/target/release/mt5-quant",
"env": {
"CUSTOM_VAR": "${env:MY_VAR}"
}
}
}
}
```
This keeps secrets out of the config file.
## Verify Setup
In Antigravity chat, type:
```
Run verify_setup
```
Expected output:
```
Wine: /Applications/MetaTrader 5.app/.../wine64
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
Display: gui
Arch: arch -x86_64
```
## Troubleshooting
### "Connection Refused" or "Tool not found"
1. Double-check the server path in `mcp_config.json`
2. Ensure the binary has execute permissions: `chmod +x /path/to/mt5-quant`
3. Try completely restarting Antigravity
4. Check the server is listed in MCP manager
### "Stdio Error" or JSON Parsing Error
1. Verify the JSON syntax in `mcp_config.json`
2. Use a JSON validator if needed
3. Ensure no trailing commas
### Agent Hallucinating Tool Parameters
If the agent makes up incorrect parameters:
1. Check the tool is actually available: "List your available tools"
2. Restart the agent session
3. Be explicit in your requests
## Configuration Location
| Platform | Path |
|----------|------|
| All | Via UI: Agent Panel → ... → MCP Servers → Manage → Edit configuration |
## Resources
- [Antigravity Documentation](https://docs.antigravity.dev/)
- [MCP Server Reference](https://github.com/modelcontextprotocol/servers)
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
+67 -37
View File
@@ -1,12 +1,14 @@
# Architecture
# Architecture Deep Dive
## Design Principles
## Design Philosophy
**Deal-level over aggregate.** MT5's HTML report gives you: profit, profit factor, max DD%, trade count. MT5-Quant extracts every individual deal — entry price, exit price, P/L, comment string — to reconstruct what happened during each loss event. Result: `analysis.json`, AI-readable and diffable between runs.
**Deal-level over aggregate.** MT5's built-in HTML report gives you: profit, profit factor, max DD%, trade count. That's it. You cannot tell from those numbers whether a drawdown was caused by overleveraged locking, a bad entry during a news spike, or a grid that reached L8 in a trending market.
**Pipeline idempotency.** MT5 caches aggressively (`.ex5` binaries, `.set` files, `terminal.ini` flags). The pipeline invalidates all cache before every run to prevent stale results.
MT5-Quant extracts every individual deal — entry price, exit price, P/L, comment string — and reconstructs what was happening when each loss event occurred. The `analysis.json` artifact is the result: AI-readable, stable schema, diffable between runs.
**Background isolation.** Genetic optimizations run 2-6 hours. The `nohup + disown` pattern prevents corruption if the parent process (SSH, Claude runner) is killed.
**Pipeline idempotency.** MT5 caches aggressively. Cached `.ex5` binaries, cached `.set` files, stale `terminal.ini` flags. The pipeline exists specifically to invalidate all of these before every run. A backtest result that came from a cached EA binary is wrong in a way that's impossible to detect without the cache clear step.
**Background isolation for optimization.** Genetic optimizations run 2-6 hours. Running them inside a parent process that can be killed (Claude task runner, SSH session, terminal) corrupts the MT5 optimization state. The only correct pattern is `nohup + disown` — fully detached from all parent process trees.
---
@@ -23,26 +25,24 @@ MT5-Quant/
│ │ ├── metrics.rs # Metrics parsing from HTML/XML
│ │ └── report.rs # Report, PipelineMetadata, etc.
│ ├── analytics/ # Report extraction & analysis (migrated from Python)
│ │ ├── extract.rs # HTML/XML report parser → metrics.json (deals → DB)
│ │ ├── extract.rs # HTML/XML report parser → metrics.json + deals.csv
│ │ └── analyze.rs # Deal-level analysis engine → analysis.json
│ ├── compile/ # MQL5 compilation
│ │ └── mql_compiler.rs # MetaEditor wrapper (Wine/CrossOver)
│ ├── pipeline/ # Backtest orchestration
│ │ ├── backtest.rs # 5-stage pipeline (COMPILE→CLEAN→BACKTEST→EXTRACT→ANALYZE)
│ │ └── stages.rs # Pipeline stage definitions
│ ├── storage/ # SQLite persistence
│ │ └── database.rs # ReportDb: reports table + deals table
│ └── tools/ # MCP tool definitions
│ ├── definitions/ # Tool schemas (9 domain modules, 90 tools)
│ ├── definitions/ # Tool schemas (9 domain modules, 43 tools)
│ │ ├── mod.rs
│ │ ├── analytics.rs # 19 analysis tools (DB-backed)
│ │ ├── backtest.rs # 7 backtest tools
│ │ ├── analytics.rs # 9 analysis tools
│ │ ├── backtest.rs # 4 backtest tools
│ │ ├── baseline.rs # 1 baseline tool
│ │ ├── experts.rs # 9 EA/indicator/script tools
│ │ ├── experts.rs # 4 EA/indicator/script tools
│ │ ├── optimization.rs # 4 optimization tools
│ │ ├── reports.rs # 20 report management tools
│ │ ├── reports.rs # 11 report management tools
│ │ ├── setfiles.rs # 8 .set file tools
│ │ └── system.rs # 6 system tools
│ │ └── system.rs # 3 system tools
│ └── handlers/ # Tool dispatch (9 domain modules)
│ ├── mod.rs
│ ├── analysis.rs
@@ -59,6 +59,11 @@ MT5-Quant/
│ ├── build-rust.sh # Rust build script
│ └── optimize.sh # Genetic optimization launcher (nohup + disown)
├── analytics/ # Legacy Python (reference only)
│ ├── extract.py
│ ├── analyze.py
│ └── optimize_parser.py
├── config/
│ ├── mt5-quant.example.yaml # Template config
│ └── mt5-quant.yaml # Live config (gitignored)
@@ -147,26 +152,17 @@ MT5 runs in headless mode, writes the report, and exits.
---
### Stage 4: EXTRACT + STORE
### Stage 4: EXTRACT
Single HTML/XML parse pass. Deals go directly into the SQLite database; the raw report file is deleted afterwards.
Single HTML/XML parse pass that produces three artifacts:
```rust
// src/analytics/extract.rs
let extractor = ReportExtractor::new();
let result = extractor.extract(&report_path, &output_dir)?;
// → metrics.json (aggregate summary — written to report_dir)
// HTML report deleted after extraction
// src/storage/database.rs
db.insert_deals(&report_id, &result.deals)?;
// → deals table in SQLite (all deals, keyed by report_id)
```
On-demand CSV export is available via the `export_deals_csv` tool:
```
export_deals_csv(report_id: "20260422_051041_DPS21_XAUUSDc_M5_1")
// → report_dir/deals.csv (written only when explicitly requested)
// → metrics.json (aggregate summary)
// → deals.csv (all deals, 13 columns)
// → deals.json (same data, JSON)
```
**Why single-pass?** MT5 HTML reports are large (1-5MB for 14-month tests). Each regex pass over the file takes ~200ms. The old pipeline ran 5 separate grep/regex passes. The Rust implementation uses a single-pass parser: 5× faster and no partial-read inconsistencies.
@@ -186,13 +182,12 @@ if ext == "xml" || path.ends_with(".htm.xml") {
}
```
**Deal columns (stored in DB):**
**Deal columns (13):**
```
time | deal | symbol | deal_type | entry | volume | price | order_id
commission | swap | profit | balance | comment | magic
Time | Type | Direction | Volume | Price | S/L | T/P | Profit | Balance | Comment | Order | Magic | Entry
```
The `comment` column is the key to grid analytics. The EA writes `"Layer #3"`, `"Locking Total"`, `"Zombie Exit"` etc. Pattern matching on comments reconstructs which position was at which layer.
The `Comment` column is the key to grid analytics. The EA writes `"Layer #3"`, `"Locking Total"`, `"Zombie Exit"` etc. Pattern matching on comments reconstructs which position was at which layer.
---
@@ -236,6 +231,15 @@ Built-in profiles:
| `trend` | — | `magic` | 240 | breakeven, trailing, partial, tp, sl |
| `hedge` | — | `magic+direction` | 120 | tp, sl, net_close, partial |
#### Entry points (after `pip install -e .`)
```bash
mt5-analyze deals.csv # generic
mt5-analyze-grid deals.csv # grid / martingale (default in pipeline)
mt5-analyze-scalper deals.csv # scalper
mt5-analyze-trend deals.csv # trend following
mt5-analyze-hedge deals.csv # hedging
```
#### Analytics functions
@@ -412,7 +416,7 @@ Then set `DISPLAY=:99` in MT5-Quant's environment config.
**Comment-based analytics:**
- Strategy-specific analytics (depth histogram, exit reason, DD cause) depend on EA comment strings. EAs that don't write structured comments will get `generic` profile results — summary metrics, session breakdown, streaks, and direction bias all still work; only keyword-classified fields fall back to `"unknown"` or profit-sign.
- Custom comment patterns can be supported by adding a new entry to `PROFILES` in `src/analytics/analyze.rs`.
- Custom comment patterns can be supported by adding a new entry to `PROFILES` in `analytics/analyze.py` — no other code changes needed.
**Single MT5 instance:**
- MT5 is single-instance per Windows drive. Two backtests cannot run simultaneously on the same Wine prefix. Parallelism requires multiple Wine prefixes (separate installations).
@@ -421,8 +425,34 @@ Then set `DISPLAY=:99` in MT5-Quant's environment config.
## Claude Code Integration
`setup.sh` generates `config/CLAUDE.template.md` — a project-level template encoding:
`setup.sh --claude-code` generates two files that give Claude persistent context about the user's trading setup:
### `config/CLAUDE.template.md`
A project-level CLAUDE.md template the user copies to their EA project root. Encodes:
- MT5-Quant tool names and when to use them
- Baseline tracking policy (compare to `baseline.json` before calling improvements)
- Symbol name reminders (`XAUUSD.cent``XAUUSD`)
- Backtest constraints (model 0, UTF-16LE .set files)
- Baseline tracking policy (never call something an improvement without comparing to `baseline.json`)
- Symbol name reminder (broker-specific suffix matters — `XAUUSD.cent``XAUUSD`)
- Backtest and optimization constraints (model 0, single instance, UTF-16LE .set files)
### `.claude/hooks/user-prompt-submit.sh`
A Claude Code hook that runs before every prompt submission. Reads `config/baseline.json` and outputs a JSON context block:
```json
{"context": "## Production Baseline (config/baseline.json)\n..."}
```
Claude Code injects this into the system context for every conversation turn. The result: Claude always knows the current production metrics without the user having to paste them.
**Hook execution path:**
```
User types prompt
→ user-prompt-submit.sh executes
→ reads config/baseline.json
→ outputs {"context": "..."} to stdout
→ Claude Code prepends to system context
→ Claude sees baseline in every prompt
```
**Graceful degradation:** If `baseline.json` doesn't exist or is malformed, the hook exits 0 silently — no prompt is blocked. The baseline section simply doesn't appear until the user creates the file.
-142
View File
@@ -1,142 +0,0 @@
# Claude Desktop MCP Integration Setup
## Quick Setup
### Option 1: Install via MCP Registry (Recommended)
Search for `mt5-quant` in Claude Desktop's MCP manager, or add directly to your config.
### Option 2: Download Prebuilt Binary
```bash
# macOS (Apple Silicon)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 3: Build from Source
```bash
git clone https://github.com/masdevid/mt5-quant
cd mt5-quant
cargo build --release
```
## Configure Claude Desktop
### Step 1: Open MCP Configuration
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows).
### Step 2: Add mt5-quant
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/absolute/path/to/mt5-quant"
}
}
}
```
### Step 3: Reload and Verify
1. Save the file
2. **Restart Claude Desktop**
3. In a new conversation, type: `What tools do you have access to?`
4. The agent should list MT5 tools like `verify_setup`, `run_backtest`, etc.
## Full Example Configuration
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/name/mt5-quant/mcp-server/bin/mt5-quant"
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${env:GITHUB_TOKEN}"
}
}
}
}
```
**Path notes:**
- Prebuilt binary: `/Users/name/mt5-quant/mcp-server/bin/mt5-quant` (extracted from release tarball)
- Dev build: `/Users/name/mt5-quant/target/release/mt5-quant` (after `cargo build --release`)
## Environment Variables
Claude Desktop supports `${env:VAR_NAME}` syntax for environment variable substitution:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/name/mt5-quant/mcp-server/bin/mt5-quant",
"env": {
"MT5_MCP_HOME": "${env:HOME}/.config/mt5-quant"
}
}
}
}
```
## Verify Setup
In a Claude Desktop conversation, type:
```
Run verify_setup
```
Expected output:
```
Wine: /Applications/MetaTrader 5.app/.../wine64
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
Display: gui
Arch: arch -x86_64
```
## Troubleshooting
### "Tool not found" or server not appearing
1. Double-check the absolute path in `claude_desktop_config.json`
2. Ensure the binary has execute permissions: `chmod +x /path/to/mt5-quant`
3. Restart Claude Desktop completely
4. Check Claude Desktop logs: `~/Library/Logs/Claude/` (macOS)
### JSON syntax errors
1. Validate the JSON at [jsonlint.com](https://jsonlint.com)
2. Ensure no trailing commas
3. Use absolute paths (not `~` or relative paths)
### Agent hallucinating tool parameters
1. Verify the tool is available: "List your available MCP tools"
2. Start a new conversation
3. Be explicit: "Use the `run_backtest` tool with expert=MyEA"
## Configuration Location
| Platform | Path |
|----------|------|
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
## Resources
- [Claude Desktop MCP Documentation](https://docs.anthropic.com/en/docs/claude-code/mcp)
- [MCP Server Reference](https://github.com/modelcontextprotocol/servers)
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
+9 -9
View File
@@ -6,12 +6,12 @@
```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
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
tar -xzf mt5.tar.gz
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 2: Build from Source
@@ -29,7 +29,7 @@ cargo build --release
3. Click **Add Custom MCP**
4. Enter:
- **Name**: `mt5-quant`
- **Command**: `/path/to/mt5-quant/mcp-server/bin/mt5-quant`
- **Command**: `~/.local/bin/mt5-quant`
- **Type**: `stdio`
### Method 2: Edit mcp.json Directly
@@ -41,7 +41,7 @@ Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
"mcpServers": {
"mt5-quant": {
"type": "stdio",
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
"command": "~/.local/bin/mt5-quant"
}
}
}
@@ -56,7 +56,7 @@ cat > ~/.cursor/mcp.json << 'EOF'
"mcpServers": {
"mt5-quant": {
"type": "stdio",
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
"command": "/path/to/mt5-quant"
}
}
}
@@ -92,7 +92,7 @@ Arch: arch -x86_64
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`
3. Test binary: `/path/to/mt5-quant --help`
4. View MCP logs: Output panel → select "MCP" from dropdown
### Config interpolation
@@ -103,7 +103,7 @@ Cursor supports variable substitution in `mcp.json`:
{
"mcpServers": {
"mt5-quant": {
"command": "${userHome}/mt5-quant/mcp-server/bin/mt5-quant",
"command": "${userHome}/bin/mt5-quant",
"args": ["--config", "${workspaceFolder}/config.yaml"]
}
}
+17 -1761
View File
File diff suppressed because it is too large Load Diff
+15 -14
View File
@@ -6,19 +6,19 @@
```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
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
tar -xzf mt5.tar.gz
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option B: Build from Source
```bash
git clone https://github.com/masdevid/mt5-quant
cd mt5-quant
git clone https://github.com/masdevid/mt5-mcp
cd mt5-mcp
bash scripts/build-rust.sh
```
@@ -82,7 +82,6 @@ terminal_dir: "~/Library/Application Support/net.metaquotes.wine.metatrader5/dri
```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:
@@ -97,8 +96,10 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
"io.github.masdevid/mt5-quant": {
"command": "~/.local/bin/mt5-quant",
"disabled": false,
"registry": "io.github.masdevid/mt5-quant"
}
}
}
@@ -112,7 +113,7 @@ Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
"command": "~/.local/bin/mt5-quant"
}
}
}
@@ -128,7 +129,7 @@ Add to `.vscode/mcp.json` in your workspace:
{
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
"command": "/path/to/mt5-quant"
}
}
}
@@ -136,7 +137,7 @@ Add to `.vscode/mcp.json` in your workspace:
Or run `MCP: Add Server` from Command Palette.
### Claude Desktop
### Antigravity
1. Open Agent panel → Click "..." menu → MCP Servers
2. Click "Manage MCP Servers"
@@ -152,7 +153,7 @@ Or run `MCP: Add Server` from Command Palette.
}
}
```
5. Restart Claude Desktop to apply changes
5. Reload Antigravity to apply changes
## 5. Verify Setup
@@ -189,4 +190,4 @@ The AI will:
---
**Next:** See [TOOLS.md](TOOLS.md) for all 89 available tools.
**Next:** See [TOOLS.md](TOOLS.md) for all 43 available tools.
-17
View File
@@ -82,23 +82,6 @@ Check `<terminal_dir>/MQL5/Logs/`:
- Check `.set` file values appropriate for symbol/broker
- Confirm `OnInit()` returns `INIT_SUCCEEDED` (MT5 Journal tab)
## Analytics Tools: "No reports in DB" or "Report not found"
Analytics tools load deals from the **SQLite database**, not from CSV files on disk. Resolution order:
1. `report_id` (preferred) — ID from `list_reports`
2. `report_dir` (legacy) — filesystem path, looks up matching DB entry
3. No args — uses the latest report automatically
Pre-DB reports (before this version) won't be found by `report_dir`. Re-run the backtest to get a DB-backed report.
**`deals.csv` is no longer written automatically.** Call `export_deals_csv` to generate one on demand:
```
export_deals_csv() # latest report → report_dir/deals.csv
export_deals_csv(report_id: "20260422_…") # specific report
export_deals_csv(output_path: "/tmp/out.csv") # custom path
```
## Optimization Never Finishes
```bash
+9 -9
View File
@@ -6,12 +6,12 @@
```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
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
tar -xzf mt5.tar.gz
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 2: Build from Source
@@ -28,7 +28,7 @@ cargo build --release
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`
5. Enter command: `~/.local/bin/mt5-quant`
### Method 2: Edit mcp.json Directly
@@ -38,7 +38,7 @@ Add to `.vscode/mcp.json` in your workspace:
{
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
"command": "~/.local/bin/mt5-quant"
}
}
}
@@ -52,7 +52,7 @@ cat > .vscode/mcp.json << 'EOF'
{
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
"command": "~/.local/bin/mt5-quant"
}
}
}
@@ -62,7 +62,7 @@ EOF
### Method 3: VS Code CLI
```bash
code --add-mcp '{"name":"mt5-quant","command":"/path/to/mt5-quant/mcp-server/bin/mt5-quant"}'
code --add-mcp '{"name":"mt5-quant","command":"~/.local/bin/mt5-quant"}'
```
## Verify Setup
@@ -115,7 +115,7 @@ Add to `.devcontainer/devcontainer.json`:
"mcp": {
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
"command": "/path/to/mt5-quant"
}
}
}
+10 -8
View File
@@ -6,12 +6,12 @@
```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
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
tar -xzf mt5.tar.gz
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 2: Build from Source
@@ -27,8 +27,10 @@ Edit `~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
"io.github.masdevid/mt5-quant": {
"command": "~/.local/bin/mt5-quant",
"disabled": false,
"registry": "io.github.masdevid/mt5-quant"
}
}
}
@@ -38,7 +40,7 @@ Or use the automated setup:
```bash
# Install binary to standard location
cp mcp-server/bin/mt5-quant ~/.local/bin/
cp target/release/mt5-quant ~/.local/bin/
# Then configure
bash scripts/setup.sh
@@ -96,7 +98,7 @@ The binary auto-detects its config location. No environment variables needed.
### 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`
3. Test executable manually: `./target/release/mt5-quant --help`
### Config not found
Set `MT5_MCP_HOME` environment variable or ensure config is at default location:
+23 -30
View File
@@ -1,11 +1,8 @@
#!/usr/bin/env bash
# Build release binary and package it for distribution
# Creates: dist/mcp-mt5-quant-{platform}.tar.gz
#
# Usage:
# bash scripts/build-release.sh
#!/bin/bash
# Build release binaries for distribution
# Creates: dist/mt5-quant-{platform}.tar.gz
set -euo pipefail
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
@@ -13,17 +10,16 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "$PROJECT_ROOT"
VERSION=$(grep -E '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "=== Building mt5-quant v${VERSION} ==="
echo "=== Building MCP-MT5-Quant v${VERSION} ==="
echo ""
# Clean previous builds
rm -rf "$PROJECT_ROOT/dist"
mkdir -p "$PROJECT_ROOT/dist"
# Build release binary
echo "Building release binary..."
# Build current platform
echo "Building for current platform..."
RUSTFLAGS="-D warnings" cargo build --release
echo ""
# Detect platform
UNAME=$(uname -s)
@@ -34,46 +30,43 @@ if [[ "$UNAME" == "Darwin" ]]; then
elif [[ "$UNAME" == "Linux" ]]; then
PLATFORM="linux-${ARCH}"
else
PLATFORM="unknown-${ARCH}"
PLATFORM="unknown"
fi
PACKAGE_NAME="mcp-mt5-quant-${PLATFORM}"
PACKAGE_DIR="$PROJECT_ROOT/dist/${PACKAGE_NAME}"
echo "Packaging for ${PLATFORM}..."
mkdir -p "$PACKAGE_DIR/docs"
mkdir -p "$PACKAGE_DIR"
# Binary
# Copy binary
cp "$PROJECT_ROOT/target/release/mt5-quant" "$PACKAGE_DIR/"
chmod +x "$PACKAGE_DIR/mt5-quant"
# Config template
# Copy config template
mkdir -p "$PACKAGE_DIR/config"
cp "$PROJECT_ROOT/config/mt5-quant.example.yaml" "$PACKAGE_DIR/config/"
# Documentation
cp "$PROJECT_ROOT/README.md" "$PACKAGE_DIR/"
cp "$PROJECT_ROOT/docs/QUICKSTART.md" "$PACKAGE_DIR/docs/"
cp "$PROJECT_ROOT/docs/CLAUDE.md" "$PACKAGE_DIR/docs/"
cp "$PROJECT_ROOT/docs/CURSOR.md" "$PACKAGE_DIR/docs/"
cp "$PROJECT_ROOT/docs/VSCODE.md" "$PACKAGE_DIR/docs/"
cp "$PROJECT_ROOT/docs/WINDSURF.md" "$PACKAGE_DIR/docs/"
# Copy docs
cp "$PROJECT_ROOT/README.md" "$PACKAGE_DIR/"
cp "$PROJECT_ROOT/docs/WINDSURF.md" "$PACKAGE_DIR/WINDSURF_SETUP.md"
cp "$PROJECT_ROOT/CLAUDE.md" "$PACKAGE_DIR/"
# Create tarball
cd "$PROJECT_ROOT/dist"
tar -czf "${PACKAGE_NAME}.tar.gz" "$PACKAGE_NAME"
rm -rf "$PACKAGE_NAME"
echo ""
echo "=== Build complete ==="
echo "=== Build Complete ==="
echo ""
echo "Package : dist/${PACKAGE_NAME}.tar.gz"
echo "Size : $(du -h "${PACKAGE_NAME}.tar.gz" | cut -f1)"
echo "Package: dist/${PACKAGE_NAME}.tar.gz"
echo "Binary: mt5-quant"
echo "Size: $(du -h "${PACKAGE_NAME}.tar.gz" | cut -f1)"
echo ""
echo "Contents:"
tar -tzf "${PACKAGE_NAME}.tar.gz"
tar -tzf "${PACKAGE_NAME}.tar.gz" | head -10
echo ""
echo "Install:"
echo "To install:"
echo " tar -xzf ${PACKAGE_NAME}.tar.gz"
echo " sudo cp ${PACKAGE_NAME}/mt5-quant /usr/local/bin/"
echo " cd ${PACKAGE_NAME}"
echo " sudo cp mt5-quant /usr/local/bin/"
echo " mt5-quant --help"
+15 -8
View File
@@ -1,25 +1,32 @@
#!/usr/bin/env bash
# Build MT5-Quant release binary
#!/bin/bash
# Build MT5-Quant Rust MCP Server
# Output: target/release/mt5-quant
set -euo pipefail
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "$PROJECT_ROOT"
echo "=== MT5-Quant build ==="
echo "Root: $PROJECT_ROOT"
echo "=== MT5-Quant Rust Build ==="
echo "Project root: $PROJECT_ROOT"
echo ""
echo "Building release binary..."
cargo build --release
echo ""
echo "=== Done ==="
echo "=== Build Complete ==="
echo ""
echo "Executable location:"
ls -lh "$PROJECT_ROOT/target/release/mt5-quant"
echo ""
echo "Run: ./target/release/mt5-quant --help"
echo "Install: cargo install --path . --force"
echo "To test:"
echo " ./target/release/mt5-quant --help"
echo ""
echo "To install for Windsurf:"
echo " Update ~/.windsurf/config.yaml:"
echo " command: $PROJECT_ROOT/target/release/mt5-quant"
echo ""
+38 -76
View File
@@ -7,7 +7,6 @@
# bash scripts/release.sh major # 1.31.0 → 2.0.0
# bash scripts/release.sh 1.32.0 # explicit version
# bash scripts/release.sh v1.32.0 # with v prefix
# bash scripts/release.sh patch --yes # non-interactive (CI / Claude Code)
set -euo pipefail
cd "$(dirname "$0")/.."
@@ -19,54 +18,44 @@ warn() { echo -e "${YELLOW}⚠ $*${NC}"; }
die() { echo -e "${RED}$*${NC}" >&2; exit 1; }
hr() { echo -e "${BLUE}────────────────────────────────────────${NC}"; }
# ── Argument parsing ───────────────────────────────────────────────────────────
# ── Prerequisites ─────────────────────────────────────────────────────────────
bump="${1:-patch}"
AUTO_YES=false
if [[ "${2:-}" == "--yes" || "${2:-}" == "-y" || "${CI:-}" == "true" ]]; then
AUTO_YES=true
fi
# ── Prerequisites ──────────────────────────────────────────────────────────────
command -v cargo >/dev/null 2>&1 || die "cargo not found"
command -v cargo >/dev/null 2>&1 || die "cargo not found"
command -v python3 >/dev/null 2>&1 || die "python3 not found"
# ── Current version ───────────────────────────────────────────────────────────
# ── Current version ───────────────────────────────────────────────────────────
current=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
[[ -n "$current" ]] || die "Could not parse version from Cargo.toml"
info "Current version: ${BOLD}$current${NC}"
# ── Compute new version ───────────────────────────────────────────────────────
# ── Compute new version ───────────────────────────────────────────────────────
bump="${1:-patch}"
IFS='.' read -r major minor patch_v <<< "$current"
case "$bump" in
major) new="$((major + 1)).0.0" ;;
minor) new="${major}.$((minor + 1)).0" ;;
patch) new="${major}.${minor}.$((patch_v + 1))" ;;
v*.*.*) new="${bump#v}" ;;
*.*.*) new="$bump" ;;
*) die "Usage: $0 [patch|minor|major|X.Y.Z] [--yes]" ;;
major) new="${major+1}.0.0"; new="$((major + 1)).0.0" ;;
minor) new="${major}.$((minor + 1)).0" ;;
patch) new="${major}.${minor}.$((patch_v + 1))" ;;
v*.*.*) new="${bump#v}" ;;
*.*.*) new="$bump" ;;
*) die "Usage: $0 [patch|minor|major|X.Y.Z]" ;;
esac
# Validate semver
[[ "$new" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "Invalid version: $new"
hr
info "Releasing: ${BOLD}$current$new${NC}"
hr
# ── Confirm ───────────────────────────────────────────────────────────────────
# ── Confirm ───────────────────────────────────────────────────────────────────
if [[ "$AUTO_YES" == false ]]; then
read -rp "Proceed with release v${new}? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || die "Aborted"
else
info "Auto-confirmed (--yes)"
fi
read -rp "Proceed with release v${new}? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || die "Aborted"
# ── Check git state ───────────────────────────────────────────────────────────
# ── Check git state ───────────────────────────────────────────────────────────
info "Checking git state..."
if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then
@@ -75,12 +64,13 @@ fi
current_branch=$(git rev-parse --abbrev-ref HEAD)
info "Branch: $current_branch"
# Check tag doesn't already exist
if git rev-parse "v${new}" >/dev/null 2>&1; then
die "Tag v${new} already exists"
fi
ok "Git state clean"
# ── 1. Bump Cargo.toml ────────────────────────────────────────────────────────
# ── 1. Bump Cargo.toml ────────────────────────────────────────────────────────
info "Bumping Cargo.toml..."
if [[ "$(uname)" == "Darwin" ]]; then
@@ -88,14 +78,16 @@ if [[ "$(uname)" == "Darwin" ]]; then
else
sed -i "s/^version = \"${current}\"/version = \"${new}\"/" Cargo.toml
fi
# Update Cargo.lock
cargo metadata --no-deps --format-version 1 >/dev/null 2>&1 || true
ok "Cargo.toml → $new"
# ── 2. Update server.json ─────────────────────────────────────────────────────
# ── 2. Update server.json ─────────────────────────────────────────────────────
info "Updating server.json..."
NEW_VERSION="$new" python3 - <<'PYEOF'
import json, os, re
import json, os, re, sys
version = os.environ['NEW_VERSION']
@@ -106,6 +98,7 @@ data['version'] = version
for pkg in data.get('packages', []):
pkg['version'] = version
# Update download URL to point at new version tag
if 'identifier' in pkg:
pkg['identifier'] = re.sub(
r'/v[0-9]+\.[0-9]+\.[0-9]+/',
@@ -121,9 +114,9 @@ with open('server.json', 'w') as f:
print(f" server.json version={version}, identifier URL updated")
PYEOF
ok "server.json → $new (SHA256 set by CI)"
ok "server.json → $new (SHA256 updated by CI)"
# ── 3. Generate CHANGELOG entry ───────────────────────────────────────────────
# ── 3. Generate CHANGELOG entry ───────────────────────────────────────────────
info "Generating CHANGELOG entry..."
prev_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
@@ -145,6 +138,7 @@ today=$(date +%Y-%m-%d)
} > /tmp/release_entry.md
if [[ -f CHANGELOG.md ]]; then
# Insert after the first header line
tmp=$(mktemp)
head -1 CHANGELOG.md > "$tmp"
echo "" >> "$tmp"
@@ -152,18 +146,22 @@ if [[ -f CHANGELOG.md ]]; then
tail -n +2 CHANGELOG.md >> "$tmp"
mv "$tmp" CHANGELOG.md
else
{ echo "# Changelog"; echo ""; cat /tmp/release_entry.md; } > CHANGELOG.md
{
echo "# Changelog"
echo ""
cat /tmp/release_entry.md
} > CHANGELOG.md
fi
rm -f /tmp/release_entry.md
ok "CHANGELOG.md updated"
# ── 4. Verify build ────────────────────────────────────────────────────────────
# ── 4. Verify build compiles ──────────────────────────────────────────────────
info "Verifying build (cargo check)..."
cargo check --quiet 2>&1 || die "cargo check failed — fix errors before releasing"
ok "Build check passed"
# ── 5. Commit ─────────────────────────────────────────────────────────────────
# ── 5. Commit ─────────────────────────────────────────────────────────────────
info "Creating release commit..."
git add Cargo.toml Cargo.lock server.json CHANGELOG.md
@@ -174,56 +172,20 @@ git commit -m "release: v${new}
- CHANGELOG.md updated"
ok "Release commit created"
# ── 6. Tag ────────────────────────────────────────────────────────────────────
# ── 6. Tag ────────────────────────────────────────────────────────────────────
info "Creating annotated tag v${new}..."
git tag -a "v${new}" -m "Release v${new}"
ok "Tagged v${new}"
# ── 7. Push (rebase if remote has new commits from CI) ─────────────────────────
# ── 7. Push ───────────────────────────────────────────────────────────────────
info "Pushing to GitHub..."
if ! git push origin "$current_branch" 2>/dev/null; then
warn "Push rejected — rebasing on remote (CI may have committed SHA256)..."
git fetch origin "$current_branch"
# Resolve server.json conflict automatically: keep our version
if ! git rebase "origin/${current_branch}"; then
if git diff --name-only --diff-filter=U | grep -q "server.json"; then
python3 - <<'PYEOF'
import re
with open('server.json') as f:
raw = f.read()
resolved = re.sub(
r'<<<<<<< HEAD.*?=======\n(.*?)>>>>>>> [^\n]+\n',
r'\1',
raw,
flags=re.DOTALL
)
with open('server.json', 'w') as f:
f.write(resolved)
PYEOF
git add server.json
GIT_EDITOR=true git rebase --continue
else
git rebase --abort
die "Rebase failed with unexpected conflicts — push manually"
fi
fi
git push origin "$current_branch"
fi
# Push tag (delete and re-push if rebase moved the commit)
git push origin "v${new}" 2>/dev/null || {
git push origin ":refs/tags/v${new}" 2>/dev/null || true
git tag -f -a "v${new}" -m "Release v${new}"
git push origin "v${new}"
}
git push origin "$current_branch"
git push origin "v${new}"
ok "Pushed — GitHub Actions triggered"
# ── Done ──────────────────────────────────────────────────────────────────────
# ── Done ──────────────────────────────────────────────────────────────────────
hr
echo ""
+2 -2
View File
@@ -14,7 +14,7 @@
# - Windsurf : ~/.codeium/windsurf/mcp_config.json (JSON, mcpServers)
# - Cursor : ~/.cursor/mcp.json (JSON, mcpServers)
# - VS Code : .vscode/mcp.json (JSON, servers - not mcpServers)
# - Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json
# - Antigravity : mcp_config.json via UI (JSON, mcpServers)
#
# Previous installations are auto-detected and uninstalled before re-registering.
@@ -1201,7 +1201,7 @@ _register_all_mcp_platforms() {
echo " - Windsurf: Edit ~/.codeium/windsurf/mcp_config.json"
echo " - Cursor: Edit ~/.cursor/mcp.json"
echo " - VS Code: Edit .vscode/mcp.json (workspace) or use MCP: Add Server command"
echo " - Claude Desktop: Edit ~/Library/Application Support/Claude/claude_desktop_config.json"
echo " - Antigravity:Use Agent panel → MCP Servers → Manage → Edit configuration"
echo ""
return
fi
+4 -4
View File
@@ -7,13 +7,13 @@
"url": "https://github.com/masdevid/mt5-quant",
"source": "github"
},
"version": "1.31.5",
"version": "1.31.2",
"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.31.2",
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.31.2/mcp-mt5-quant-macos-arm64.tar.gz",
"fileSha256": "TBD_CI_WILL_UPDATE",
"transport": {
"type": "stdio"
},
+19 -5
View File
@@ -15,7 +15,7 @@ impl ReportExtractor {
pub fn extract(&self, report_path: &str, output_dir: &str) -> Result<ExtractionResult> {
let format = Self::detect_format(report_path);
let (metrics, deals) = match format {
ReportFormat::Xml => self.parse_xml(report_path)?,
ReportFormat::Html => self.parse_html(report_path)?,
@@ -24,19 +24,22 @@ impl ReportExtractor {
fs::create_dir_all(output_dir)?;
let metrics_path = Path::new(output_dir).join("metrics.json");
let deals_csv_path = Path::new(output_dir).join("deals.csv");
let deals_json_path = Path::new(output_dir).join("deals.json");
self.write_metrics(&metrics, &metrics_path)?;
self.write_deals_json(&deals, &deals_json_path)?;
self.write_deals_csv(&deals, &deals_csv_path)?;
Ok(ExtractionResult {
metrics,
deals,
metrics_path,
deals_csv_path,
deals_json_path,
})
}
pub fn write_deals_to_csv(&self, deals: &[Deal], path: &Path) -> Result<()> {
self.write_deals_csv(deals, path)
}
fn detect_format(path: &str) -> ReportFormat {
if path.ends_with(".xml") || path.ends_with(".htm.xml") {
return ReportFormat::Xml;
@@ -212,6 +215,13 @@ impl ReportExtractor {
Ok(())
}
fn write_deals_json(&self, deals: &[Deal], path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(deals)?;
let mut file = File::create(path)?;
file.write_all(json.as_bytes())?;
Ok(())
}
fn write_deals_csv(&self, deals: &[Deal], path: &Path) -> Result<()> {
let mut file = File::create(path)?;
writeln!(file, "time,deal,symbol,type,entry,volume,price,order,commission,swap,profit,balance,comment")?;
@@ -269,6 +279,10 @@ pub struct ExtractionResult {
pub deals: Vec<Deal>,
#[allow(dead_code)]
pub metrics_path: PathBuf,
#[allow(dead_code)]
pub deals_csv_path: PathBuf,
#[allow(dead_code)]
pub deals_json_path: PathBuf,
}
#[derive(Debug, Clone, Copy)]
-93
View File
@@ -12,14 +12,10 @@ use anyhow::Result;
use clap::Parser;
use serde_json::{json, Value};
use std::io::{stdout, Write};
use std::time::Instant;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tracing::{info, error};
use crate::models::Config;
use crate::pipeline::backtest::{BacktestPipeline, BacktestParams};
#[derive(Parser)]
#[command(name = "mt5-quant")]
#[command(about = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP")]
@@ -31,18 +27,6 @@ struct Cli {
/// Run on TCP port for debugging
#[arg(short, long)]
port: Option<u16>,
/// Test backtest launch performance (direct Rust call, not MCP)
#[arg(long)]
test_launch: bool,
/// EA name for test launch
#[arg(long)]
ea: Option<String>,
/// Startup delay for test launch (default: 10)
#[arg(long)]
startup_delay: Option<u64>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
@@ -104,11 +88,6 @@ async fn main() -> Result<()> {
let cli = Cli::parse();
if cli.test_launch {
run_test_launch(cli.ea, cli.startup_delay).await?;
return Ok(());
}
if let Some(port) = cli.port {
run_tcp_server(port).await?;
} else {
@@ -118,82 +97,10 @@ async fn main() -> Result<()> {
Ok(())
}
async fn run_test_launch(ea: Option<String>, startup_delay: Option<u64>) -> Result<()> {
let expert = ea.ok_or_else(|| anyhow::anyhow!("--ea is required for test launch"))?;
let delay = startup_delay.unwrap_or(10);
println!("Testing MT5 backtest launch optimizations...");
println!("==============================================");
println!("EA: {}", expert);
println!("Startup delay: {}s", delay);
let config = Config::load()?;
let params = BacktestParams {
expert: expert.clone(),
symbol: "XAUUSD".to_string(),
from_date: "2024.01.01".to_string(),
to_date: "2024.01.31".to_string(),
timeframe: "M5".to_string(),
deposit: 10000,
model: 0,
leverage: 500,
set_file: None,
skip_compile: true,
skip_clean: false,
skip_analyze: true,
deep_analyze: false,
shutdown: false,
kill_existing: false,
timeout: 900,
gui: false,
startup_delay_secs: delay,
};
let pipeline = BacktestPipeline::new(config);
println!("\nLaunching backtest...");
let start = Instant::now();
match pipeline.launch_backtest(params).await {
Ok(job) => {
let elapsed = start.elapsed();
println!("✓ Launch completed in {:.2}s", elapsed.as_secs_f64());
println!(" Report ID: {}", job.report_id);
println!(" Report dir: {}", job.report_dir);
println!("\nUse get_backtest_status to monitor progress.");
}
Err(e) => {
let elapsed = start.elapsed();
println!("✗ Launch failed after {:.2}s: {}", elapsed.as_secs_f64(), e);
}
}
println!("\n==============================================");
println!("Test complete.");
Ok(())
}
async fn run_stdio_server() -> Result<()> {
info!("Starting MT5-Quant MCP server on stdio");
let server = std::sync::Arc::new(mcp_server::McpServer::new());
let (notification_tx, mut notification_rx) = tokio::sync::mpsc::unbounded_channel::<mcp_server::Notification>();
server.set_notification_sender(notification_tx).await;
// Spawn notification sender task
tokio::spawn(async move {
while let Some(notification) = notification_rx.recv().await {
let notification_json = json!({
"jsonrpc": "2.0",
"method": notification.method,
"params": notification.params,
});
println!("{}", notification_json);
let _ = stdout().flush();
}
});
let mut reader = BufReader::new(tokio::io::stdin());
let mut line = String::new();
+11 -60
View File
@@ -1,11 +1,9 @@
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::{mpsc, Mutex};
use tokio::sync::Mutex;
use crate::{models::Config as ModelsConfig, tools::ToolHandler, McpError, McpRequest, McpResponse};
type NotificationCallback = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
/// Auto-verify result stored after first initialization
#[derive(Debug, Clone)]
#[allow(dead_code)]
@@ -15,17 +13,11 @@ struct AutoVerifyResult {
config_path: String,
}
#[derive(Debug, Clone)]
pub struct Notification {
pub method: String,
pub params: Value,
}
#[derive(Debug)]
pub struct McpServer {
initialized: Arc<Mutex<bool>>,
tool_handler: Arc<Mutex<Option<ToolHandler>>>,
tool_handler: Arc<ToolHandler>,
auto_verify_result: Arc<Mutex<Option<AutoVerifyResult>>>,
notification_tx: Arc<Mutex<Option<mpsc::UnboundedSender<Notification>>>>,
}
impl McpServer {
@@ -33,37 +25,11 @@ impl McpServer {
let config = ModelsConfig::load().unwrap_or_default();
Self {
initialized: Arc::new(Mutex::new(false)),
tool_handler: Arc::new(Mutex::new(Some(ToolHandler::new(config)))),
tool_handler: Arc::new(ToolHandler::new(config)),
auto_verify_result: Arc::new(Mutex::new(None)),
notification_tx: Arc::new(Mutex::new(None)),
}
}
pub async fn set_notification_sender(&self, tx: mpsc::UnboundedSender<Notification>) {
let mut guard = self.notification_tx.lock().await;
*guard = Some(tx.clone());
// Update tool handler with notification callback
let tx_clone = tx.clone();
let callback = Arc::new(move |method: &str, params: serde_json::Value| {
let _ = tx_clone.send(Notification {
method: method.to_string(),
params,
});
});
let config = ModelsConfig::load().unwrap_or_default();
let new_handler = ToolHandler::with_notification_callback(config, callback);
let mut handler_guard = self.tool_handler.lock().await;
*handler_guard = Some(new_handler);
}
pub async fn get_notification_sender(&self) -> Option<mpsc::UnboundedSender<Notification>> {
let guard = self.notification_tx.lock().await;
guard.clone()
}
/// Run verify_setup in background - non blocking
fn spawn_auto_verify(&self) {
let result_arc = self.auto_verify_result.clone();
@@ -281,27 +247,12 @@ impl McpServer {
}
async fn handle_tool_call(&self, tool_name: &str, arguments: &Value) -> Value {
let handler_guard = self.tool_handler.lock().await;
let handler = handler_guard.as_ref().cloned();
drop(handler_guard);
match handler {
Some(h) => {
h.handle(tool_name, arguments).await.unwrap_or_else(|e| json!({
"content": [{
"type": "text",
"text": format!("Tool execution failed: {}", e)
}],
"isError": true
}))
}
None => json!({
"content": [{
"type": "text",
"text": "Tool handler not initialized"
}],
"isError": true
})
}
self.tool_handler.handle(tool_name, arguments).await.unwrap_or_else(|e| json!({
"content": [{
"type": "text",
"text": format!("Tool execution failed: {}", e)
}],
"isError": true
}))
}
}
-4
View File
@@ -97,7 +97,6 @@ pub struct Config {
pub reports_dir: Option<String>,
pub backtest_login: Option<String>,
pub backtest_server: Option<String>,
pub backtest_password: Option<String>,
pub project_dir: Option<String>,
}
@@ -124,7 +123,6 @@ impl Default for Config {
reports_dir: None,
backtest_login: None,
backtest_server: None,
backtest_password: None,
project_dir: None,
}
}
@@ -406,7 +404,6 @@ impl Config {
reports_dir: map.get("reports_dir").cloned(),
backtest_login: map.get("backtest_login").cloned(),
backtest_server: map.get("backtest_server").cloned(),
backtest_password: map.get("backtest_password").cloned(),
project_dir: map.get("project_dir").cloned(),
})
}
@@ -433,7 +430,6 @@ impl Config {
"reports_dir" => self.reports_dir.clone().unwrap_or_else(|| "reports".to_string()),
"backtest_login" => self.backtest_login.clone().unwrap_or_default(),
"backtest_server" => self.backtest_server.clone().unwrap_or_default(),
"backtest_password" => self.backtest_password.clone().unwrap_or_default(),
"project_dir" => self.project_dir.clone().unwrap_or_default(),
_ => String::new(),
}
+75 -245
View File
@@ -1,10 +1,8 @@
use anyhow::{anyhow, Result};
use chrono;
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use tokio::time::{sleep, Duration};
use crate::analytics::{DealAnalyzer, ReportExtractor};
@@ -13,14 +11,11 @@ use crate::models::config::Config;
use crate::models::report::{PipelineMetadata, FilePaths, BacktestJob};
use crate::storage::{ReportDb, ReportEntry};
type NotificationCallback = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
pub struct BacktestPipeline {
config: Config,
compiler: MqlCompiler,
extractor: ReportExtractor,
analyzer: DealAnalyzer,
notification_callback: Option<NotificationCallback>,
}
pub struct BacktestParams {
@@ -43,7 +38,6 @@ pub struct BacktestParams {
pub kill_existing: bool,
pub timeout: u64,
pub gui: bool,
pub startup_delay_secs: u64,
}
pub struct PipelineResult {
@@ -64,21 +58,6 @@ impl BacktestPipeline {
compiler,
extractor,
analyzer,
notification_callback: None,
}
}
pub fn with_notification_callback(config: Config, callback: NotificationCallback) -> Self {
let compiler = MqlCompiler::new(config.clone());
let extractor = ReportExtractor::new();
let analyzer = DealAnalyzer::new();
Self {
config,
compiler,
extractor,
analyzer,
notification_callback: Some(callback),
}
}
@@ -138,8 +117,8 @@ impl BacktestPipeline {
let duration = (chrono::Utc::now() - start_time).num_seconds();
self.save_metadata(&params, &report_dir, duration, extraction.deals.is_empty()).await?;
// Register in the SQLite report registry and store deals.
let db = self.register_in_db(
// Register in the SQLite report registry.
self.register_in_db(
&report_id,
&params,
&report_dir,
@@ -150,12 +129,6 @@ impl BacktestPipeline {
)
.await;
if let Some(db) = db {
if let Err(e) = db.insert_deals(&report_id, &extraction.deals) {
tracing::warn!("Failed to store deals in DB: {}", e);
}
}
let message = if extraction.deals.is_empty() {
"Backtest completed successfully, but EA did not execute any trades during this period".to_string()
} else {
@@ -208,7 +181,7 @@ 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).
// Write config files
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())?;
@@ -252,122 +225,9 @@ impl BacktestPipeline {
tracing::warn!("Failed to init report DB: {}", e);
}
// Spawn background task to monitor completion and update status
let report_dir_clone = report_dir.clone();
let expected_report_clone = expected_report.clone();
let timeout_secs = params.timeout;
let report_id_clone = report_id.clone();
let notification_callback = self.notification_callback.clone();
tokio::spawn(async move {
Self::monitor_backtest_completion(
report_dir_clone,
expected_report_clone,
timeout_secs,
report_id_clone,
notification_callback,
).await;
});
Ok(job)
}
/// Background task to monitor backtest completion and update status file.
async fn monitor_backtest_completion(
report_dir: PathBuf,
expected_report: PathBuf,
timeout_secs: u64,
report_id: String,
notification_callback: Option<NotificationCallback>,
) {
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();
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('.'));
if candidate.exists() {
tracing::info!("Backtest {} completed: report found at {}", report_id, candidate.display());
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!({
"report_id": report_id,
"report_path": candidate.to_string_lossy().to_string(),
"status": "completed"
}));
}
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());
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,
"report_path": path.to_string_lossy().to_string(),
"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;
}
if tokio::time::Instant::now() > deadline {
tracing::warn!("Backtest {} timed out after {} seconds", report_id, timeout_secs);
Self::update_job_status(&report_dir, "timeout", None).await;
if let Some(ref callback) = notification_callback {
callback("backtest_timeout", json!({
"report_id": report_id,
"status": "timeout",
"timeout_seconds": timeout_secs
}));
}
return;
}
sleep(Duration::from_secs(2)).await;
}
}
/// 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");
if let Ok(job_json) = fs::read_to_string(&job_path) {
if let Ok(mut job) = serde_json::from_str::<serde_json::Value>(&job_json) {
job["status"] = serde_json::Value::String(status.to_string());
job["completed_at"] = serde_json::Value::String(chrono::Utc::now().to_rfc3339());
if let Some(path) = report_path {
job["actual_report_path"] = serde_json::Value::String(path);
}
if let Ok(updated) = serde_json::to_string_pretty(&job) {
let _ = fs::write(&job_path, updated);
}
}
}
}
/// Move equity chart images (*.png, *.gif) from MT5's reports dir to OS temp,
/// returning the temp path if any images were found.
async fn relocate_charts(&self, html_path: &Path, report_id: &str) -> Option<PathBuf> {
@@ -429,11 +289,11 @@ impl BacktestPipeline {
set_snapshot: Option<&Path>,
metrics: &crate::models::metrics::Metrics,
duration: i64,
) -> Option<ReportDb> {
) {
let db = ReportDb::new(&Config::db_path());
if let Err(e) = db.init() {
tracing::warn!("Failed to init report DB: {}", e);
return None;
return;
}
let entry = ReportEntry {
@@ -467,10 +327,7 @@ impl BacktestPipeline {
if let Err(e) = db.insert(&entry) {
tracing::warn!("Failed to register report in DB: {}", e);
return None;
}
Some(db)
}
async fn compile_ea(&self, expert: &str, timeout_secs: u64) -> Result<()> {
@@ -594,8 +451,12 @@ 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).
// Write backtest_config.ini (used by wine64 shell-script launch via /config:)
// and also patch terminal.ini so the Strategy Tester panel shows the right
// settings if the user opens MT5 manually.
let ini_content = self.build_backtest_ini(params, report_id)?;
// Write to drive_c root (C:\backtest_config.ini) — no spaces in path avoids
// Wine argument-quoting issues when MT5 parses the /config: value.
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)?;
@@ -616,10 +477,8 @@ impl BacktestPipeline {
.spawn()?;
// Give MT5 time to fully initialize before polling.
// MT5 app startup (Wine init + network auth + tester) typically takes 1015 s.
// Configurable via startup_delay_secs parameter (default 10s for faster launches).
let delay = if params.startup_delay_secs > 0 { params.startup_delay_secs } else { 10 };
sleep(Duration::from_secs(delay)).await;
// MT5 app startup (Wine init + network auth + tester) typically takes 1520 s.
sleep(Duration::from_secs(20)).await;
// Poll for the report file (MT5 writes it when the backtest completes).
// Grace period: don't check process liveness for the first 30 s after launch —
@@ -664,21 +523,15 @@ impl BacktestPipeline {
}
}
/// Write backtest params into terminal.ini [Tester] section.
/// MT5 uses this when restarting — it reconnects via the saved session in common.ini
/// rather than requiring fresh credentials. This is more reliable than /config: alone,
/// which requires a password for fresh authentication.
/// Write backtest parameters into terminal.ini [Tester] section so MT5 reads
/// them on startup without needing a /config: command-line argument.
fn update_terminal_ini(&self, params: &BacktestParams, report_id: &str) -> Result<()> {
let mt5_dir = self.config.mt5_dir()
.ok_or_else(|| anyhow!("MT5 directory not configured"))?;
// Portable mode uses config/ inside the install dir; non-portable uses the root.
let terminal_ini = if mt5_dir.join("config").exists() {
mt5_dir.join("config").join("terminal.ini")
} else {
mt5_dir.join("terminal.ini")
};
let terminal_ini = mt5_dir.join("config").join("terminal.ini");
let raw = fs::read(&terminal_ini).unwrap_or_default();
let raw = fs::read(&terminal_ini)
.unwrap_or_default();
let text = if raw.starts_with(&[0xFF, 0xFE]) {
raw[2..].chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
@@ -691,31 +544,21 @@ impl BacktestPipeline {
};
let period = match params.timeframe.as_str() {
"M1" => 1u32, "M5" => 5, "M15" => 15, "M30" => 30,
"H1" => 60, "H4" => 240, "D1" => 1440,
_ => 5,
"M1" => 1u32, "M5" => 5, "M15" => 15, "M30" => 30,
"H1" => 60, "H4" => 240, "D1" => 1440,
_ => 5,
};
let from_ts = Self::date_str_to_unix(&params.from_date)?;
let to_ts = Self::date_str_to_unix(&params.to_date)?;
let currency = self.config.backtest_currency.as_deref().unwrap_or("USD");
let expert_path = if let Some(experts_dir) = &self.config.experts_dir {
let nested = std::path::Path::new(experts_dir).join(&params.expert).join(format!("{}.mq5", params.expert));
if nested.exists() {
format!("Experts\\{}\\{}.ex5", params.expert, params.expert)
} else {
format!("Experts\\{}.ex5", params.expert)
}
} else {
format!("Experts\\{}.ex5", params.expert)
};
let set_file_line = params.set_file.as_ref()
.map(|p| format!("ExpertParameters={}\n", p))
.unwrap_or_default();
let updates: &[(&str, String)] = &[
("Expert", expert_path),
("Expert", self.resolve_expert_path(&params.expert)),
("Symbol", params.symbol.clone()),
("Period", period.to_string()),
("DateRange", "3".into()),
@@ -734,15 +577,31 @@ impl BacktestPipeline {
("ShutdownTerminal", if params.shutdown { "1" } else { "0" }.into()),
];
let updated = Self::patch_ini_section(&text, "Tester", updates) + &set_file_line;
let updated = Self::patch_ini_section(&text, "Tester", updates)
+ &set_file_line;
let bom_utf16: Vec<u8> = [0xFF, 0xFE].iter().copied()
.chain(updated.encode_utf16().flat_map(|c| c.to_le_bytes()))
.collect();
fs::write(&terminal_ini, bom_utf16)?;
tracing::info!("terminal.ini [Tester] updated {}", terminal_ini.display());
tracing::info!("terminal.ini [Tester] updated for backtest {}", report_id);
Ok(())
}
/// Parse "YYYY.MM.DD" and return a Unix timestamp (seconds since 1970-01-01 UTC).
fn date_str_to_unix(date: &str) -> Result<i64> {
let parts: Vec<u32> = date.split('.').filter_map(|p| p.parse().ok()).collect();
if parts.len() != 3 {
return Err(anyhow!("Invalid date format: {}", date));
}
let dt = chrono::NaiveDate::from_ymd_opt(parts[0] as i32, parts[1], parts[2])
.ok_or_else(|| anyhow!("Invalid date: {}", date))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow!("Date conversion failed"))?;
Ok(chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(dt, chrono::Utc).timestamp())
}
/// Replace or add key=value pairs in a named INI section.
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);
@@ -759,6 +618,7 @@ impl BacktestPipeline {
continue;
}
if trimmed.starts_with('[') && in_section {
// End of our section — flush any keys not yet written
for (k, v) in &pending {
result.push_str(&format!("{}={}\n", k, v));
}
@@ -777,6 +637,7 @@ impl BacktestPipeline {
result.push_str(line);
result.push('\n');
}
// Flush remaining keys if section was at end of file
if in_section {
for (k, v) in &pending {
result.push_str(&format!("{}={}\n", k, v));
@@ -785,33 +646,19 @@ impl BacktestPipeline {
result
}
fn date_str_to_unix(date: &str) -> Result<i64> {
let parts: Vec<u32> = date.split('.').filter_map(|p| p.parse().ok()).collect();
if parts.len() != 3 {
return Err(anyhow!("Invalid date format: {}", date));
}
let dt = chrono::NaiveDate::from_ymd_opt(parts[0] as i32, parts[1], parts[2])
.ok_or_else(|| anyhow!("Invalid date: {}", date))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow!("Date conversion failed"))?;
Ok(chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(dt, chrono::Utc).timestamp())
}
/// Build the OS-appropriate command to launch MT5 with the backtest config.
///
/// - macOS MT5.app bundle: use shell script to bypass SIP and set DYLD vars.
/// Relies on terminal.ini for backtest config (more reliable than /config:).
/// - macOS MT5.app bundle: use `open -a "MetaTrader 5" --args /config:...`
/// The native launcher handles Wine env setup (DYLD vars are stripped by SIP
/// when set on child processes spawned from a Rust binary).
/// - macOS CrossOver / Linux Wine: standard WINEPREFIX + wine64 direct invocation.
/// Also relies on terminal.ini for backtest config.
fn build_wine_launch(&self, wine_exe: &str, wine_prefix: &Path) -> Result<Command> {
if wine_exe.contains("MetaTrader 5.app") {
// macOS MT5.app — the Swift launcher ignores --args so we can't pass
// /config: via `open`. Instead, write a temp shell script that sets
// DYLD_FALLBACK_LIBRARY_PATH and invokes wine64 directly.
// DYLD_FALLBACK_LIBRARY_PATH and invokes wine64 with /config: directly.
// Shell scripts bypass the SIP restriction that strips DYLD_* vars
// when Rust spawns a codesigned binary as a direct child process.
// NOTE: We rely on terminal.ini for config instead of /config: because
// MT5.app's bundled wine64 doesn't reliably handle /config: arguments.
let wine_bin = Path::new(wine_exe);
let wine_root = wine_bin
.parent() // bin/
@@ -824,13 +671,11 @@ impl BacktestPipeline {
let dyld = format!("{}:{}:/usr/lib:/usr/local/lib",
ext_libs.display(), wine_libs.display());
// Use host path for the exe; use /config: with backslash-escaped path
// Use host path for the exe; config at drive root to avoid spaces in path.
let terminal_host = wine_prefix.join("drive_c")
.join("Program Files").join("MetaTrader 5").join("terminal64.exe");
// /config: triggers the Strategy Tester to auto-start.
// terminal.ini is also patched with the same params as a belt-and-suspenders.
let config_win = r"C:\backtest_config.ini";
let script = format!(
"#!/bin/sh\n\
export DYLD_FALLBACK_LIBRARY_PATH='{dyld}'\n\
@@ -846,41 +691,43 @@ 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());
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::info!("Launching MT5 via shell script (terminal.ini mode): {}", script_path.display());
tracing::info!("Launching MT5 via shell script: {}", script_path.display());
let mut cmd = Command::new("/bin/sh");
cmd.arg(&script_path);
return Ok(cmd);
}
// CrossOver / Linux: invoke wine64 directly with /config: to trigger the tester.
// CrossOver / Linux: invoke wine64 directly with WINEPREFIX set.
// Params are already written to terminal.ini so no /config: arg needed.
let terminal_win_path = r"C:\Program Files\MetaTrader 5\terminal64.exe";
let config_win = r"C:\backtest_config.ini";
let mut cmd = Command::new(wine_exe);
cmd.arg(terminal_win_path)
.arg(format!("/config:{}", config_win))
.env("WINEPREFIX", wine_prefix)
.env("WINEDEBUG", "-all");
Ok(cmd)
}
/// For terminal.ini: path relative to MQL5/ (e.g. `Experts\DPS21\DPS21.ex5`).
fn resolve_expert_path(&self, expert: &str) -> String {
if let Some(experts_dir) = &self.config.experts_dir {
let nested_ex5 = PathBuf::from(experts_dir).join(expert).join(format!("{}.ex5", expert));
let nested_mq5 = PathBuf::from(experts_dir).join(expert).join(format!("{}.mq5", expert));
if nested_ex5.exists() || nested_mq5.exists() {
return format!("Experts\\{}\\{}.ex5", expert, expert);
}
}
format!("Experts\\{}.ex5", expert)
}
/// For /config: INI: path relative to MQL5/Experts/ (e.g. `DPS21\DPS21.ex5`).
/// The /config: format does NOT include the "Experts\" prefix.
fn resolve_backtest_ini_expert_path(&self, expert: &str) -> String {
@@ -897,17 +744,11 @@ impl BacktestPipeline {
fn build_backtest_ini(&self, params: &BacktestParams, report_id: &str) -> Result<String> {
let mut ini = String::new();
// [Common] section: only written when explicit credentials are configured.
// Without it, MT5 reuses its saved session via common.ini (no password needed).
if let Some(login) = &self.config.backtest_login {
if let Some(server) = &self.config.backtest_server {
ini.push_str("[Common]\n");
ini.push_str(&format!("Login={}\n", login));
ini.push_str(&format!("Server={}\n", server));
if let Some(password) = &self.config.backtest_password {
ini.push_str(&format!("Password={}\n", password));
}
ini.push_str("\n");
ini.push_str(&format!("Server={}\n\n", server));
}
}
@@ -955,22 +796,11 @@ 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 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
let _ = Command::new("pkill").args(["-KILL", "-f", pat.as_str()]).output();
}
let _ = Command::new("pkill").args(["-KILL", "-f", "wineserver"]).output();
sleep(Duration::from_secs(3)).await;
Ok(())
}
-135
View File
@@ -3,8 +3,6 @@ use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use crate::models::Deal;
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct ReportEntry {
pub id: String,
@@ -69,25 +67,6 @@ impl ReportDb {
}
let conn = self.connect()?;
conn.execute_batch("
CREATE TABLE IF NOT EXISTS deals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
report_id TEXT NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
time TEXT NOT NULL,
deal TEXT NOT NULL,
symbol TEXT NOT NULL,
deal_type TEXT NOT NULL,
entry TEXT NOT NULL,
volume REAL NOT NULL,
price REAL NOT NULL,
order_id TEXT NOT NULL,
commission REAL NOT NULL,
swap REAL NOT NULL,
profit REAL NOT NULL,
balance REAL NOT NULL,
comment TEXT NOT NULL,
magic TEXT
);
CREATE INDEX IF NOT EXISTS idx_deals_report_id ON deals(report_id);
CREATE TABLE IF NOT EXISTS reports (
id TEXT PRIMARY KEY,
expert TEXT NOT NULL,
@@ -123,69 +102,6 @@ impl ReportDb {
Ok(())
}
pub fn insert_deals(&self, report_id: &str, deals: &[Deal]) -> Result<()> {
if deals.is_empty() {
return Ok(());
}
let conn = self.connect()?;
let mut stmt = conn.prepare(
"INSERT INTO deals (report_id, time, deal, symbol, deal_type, entry, volume, price, \
order_id, commission, swap, profit, balance, comment, magic) \
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)",
)?;
for d in deals {
stmt.execute(params![
report_id,
d.time,
d.deal,
d.symbol,
d.deal_type,
d.entry,
d.volume,
d.price,
d.order,
d.commission,
d.swap,
d.profit,
d.balance,
d.comment,
d.magic,
])?;
}
Ok(())
}
pub fn get_deals(&self, report_id: &str) -> Result<Vec<Deal>> {
let conn = self.connect()?;
let mut stmt = conn.prepare(
"SELECT time, deal, symbol, deal_type, entry, volume, price, order_id, \
commission, swap, profit, balance, comment, magic \
FROM deals WHERE report_id = ? ORDER BY id ASC",
)?;
let deals: Vec<Deal> = stmt
.query_map([report_id], |row| {
Ok(Deal {
time: row.get(0)?,
deal: row.get(1)?,
symbol: row.get(2)?,
deal_type: row.get(3)?,
entry: row.get(4)?,
volume: row.get(5)?,
price: row.get(6)?,
order: row.get(7)?,
commission: row.get(8)?,
swap: row.get(9)?,
profit: row.get(10)?,
balance: row.get(11)?,
comment: row.get(12)?,
magic: row.get(13)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(deals)
}
pub fn insert(&self, entry: &ReportEntry) -> Result<()> {
let conn = self.connect()?;
let tags_json = serde_json::to_string(&entry.tags)?;
@@ -459,57 +375,6 @@ impl ReportDb {
Ok(entry)
}
/// Get a specific report by its report_dir path (exact match)
pub fn get_by_report_dir(&self, report_dir: &str) -> Result<Option<ReportEntry>> {
let conn = self.connect()?;
let mut stmt = conn.prepare(
"SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE report_dir = ?"
)?;
let entry = stmt
.query_map([report_dir], |row| {
let tags_json: String = row.get(23)?;
let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.next();
Ok(entry)
}
/// Get a specific report by ID
pub fn get_by_id(&self, id: &str) -> Result<Option<ReportEntry>> {
let conn = self.connect()?;
+29 -43
View File
@@ -1,8 +1,5 @@
use serde_json::{json, Value};
// Common description suffix for all analytics tools
const REPORT_HINT: &str = "report_id (preferred), report_dir (legacy path), or omit for latest report.";
pub fn tool_analyze_report() -> Value {
json!({
"name": "analyze_report",
@@ -10,8 +7,7 @@ pub fn tool_analyze_report() -> Value {
"inputSchema": {
"type": "object",
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory (use report_id instead)" },
"report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" },
"analytics": {
"type": "array",
"description": "Optional: specific analytics to run. If omitted, runs all.",
@@ -33,8 +29,7 @@ pub fn tool_analyze_monthly_pnl() -> Value {
"inputSchema": {
"type": "object",
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" }
}
}
})
@@ -47,8 +42,7 @@ pub fn tool_analyze_drawdown_events() -> Value {
"inputSchema": {
"type": "object",
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
@@ -61,8 +55,7 @@ pub fn tool_analyze_top_losses() -> Value {
"inputSchema": {
"type": "object",
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" },
"report_dir": { "type": "string" },
"limit": { "type": "integer", "description": "Number of losses to return (default: 10)", "default": 10 }
}
}
@@ -76,8 +69,7 @@ pub fn tool_analyze_loss_sequences() -> Value {
"inputSchema": {
"type": "object",
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
@@ -90,8 +82,7 @@ pub fn tool_analyze_position_pairs() -> Value {
"inputSchema": {
"type": "object",
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
@@ -104,8 +95,7 @@ pub fn tool_analyze_direction_bias() -> Value {
"inputSchema": {
"type": "object",
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
@@ -118,8 +108,7 @@ pub fn tool_analyze_streaks() -> Value {
"inputSchema": {
"type": "object",
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
@@ -132,8 +121,7 @@ pub fn tool_analyze_concurrent_peak() -> Value {
"inputSchema": {
"type": "object",
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
@@ -145,9 +133,9 @@ pub fn tool_list_deals() -> Value {
"description": "List individual deals from a backtest report with optional filters",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" },
"report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" },
"deal_type": { "type": "string", "enum": ["buy", "sell"], "description": "Filter by deal type" },
"min_profit": { "type": "number", "description": "Minimum profit (use negative for losses)" },
"max_profit": { "type": "number", "description": "Maximum profit" },
@@ -167,10 +155,9 @@ pub fn tool_search_deals_by_comment() -> Value {
"description": "Search deals by comment text (case-insensitive partial match)",
"inputSchema": {
"type": "object",
"required": ["query"],
"required": ["report_dir", "query"],
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" },
"report_dir": { "type": "string" },
"query": { "type": "string", "description": "Search text in comments" },
"limit": { "type": "integer", "default": 50 }
}
@@ -184,10 +171,9 @@ pub fn tool_search_deals_by_magic() -> Value {
"description": "Filter deals by magic number (EA identifier)",
"inputSchema": {
"type": "object",
"required": ["magic"],
"required": ["report_dir", "magic"],
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" },
"report_dir": { "type": "string" },
"magic": { "type": "string", "description": "Magic number to filter by" },
"limit": { "type": "integer", "default": 100 }
}
@@ -201,9 +187,9 @@ pub fn tool_analyze_profit_distribution() -> Value {
"description": "Analyze profit distribution - small/medium/large wins and losses with detailed buckets",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
@@ -215,9 +201,9 @@ pub fn tool_analyze_time_performance() -> Value {
"description": "Analyze performance by hour of day and day of week",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
@@ -229,9 +215,9 @@ pub fn tool_analyze_hold_time_distribution() -> Value {
"description": "Analyze hold time distribution and correlation with profit",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
@@ -243,9 +229,9 @@ pub fn tool_analyze_layer_performance() -> Value {
"description": "Analyze performance by grid layer (extracted from deal comments)",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
@@ -257,9 +243,9 @@ pub fn tool_analyze_volume_vs_profit() -> Value {
"description": "Analyze correlation between volume and profit, plus performance by volume bucket",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
@@ -271,9 +257,9 @@ pub fn tool_analyze_costs() -> Value {
"description": "Analyze commission and swap costs impact on profitability",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
@@ -285,9 +271,9 @@ pub fn tool_analyze_efficiency() -> Value {
"description": "Calculate efficiency metrics: profit per hour/day, annualized return, trade frequency",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_id": { "type": "string", "description": REPORT_HINT },
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
"report_dir": { "type": "string" }
}
}
})
+3 -6
View File
@@ -23,8 +23,7 @@ pub fn tool_run_backtest() -> Value {
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest completes" },
"kill_existing": { "type": "boolean", "description": "Kill any running MT5 instance first" },
"timeout": { "type": "integer", "description": "Max wait time 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, set to 0 for default 10s)" }
"gui": { "type": "boolean", "description": "Enable MT5 visualization window" }
}
}
})
@@ -49,8 +48,7 @@ pub fn tool_run_backtest_quick() -> Value {
"deep": { "type": "boolean", "description": "Run deep analysis" },
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest" },
"timeout": { "type": "integer", "description": "Max wait time in seconds (default: 900)" },
"gui": { "type": "boolean", "description": "Enable MT5 visualization" },
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" }
"gui": { "type": "boolean", "description": "Enable MT5 visualization" }
}
}
})
@@ -99,8 +97,7 @@ 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)" },
"gui": { "type": "boolean", "description": "Enable visualization during backtest" },
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" }
"gui": { "type": "boolean", "description": "Enable visualization during backtest" }
}
}
})
-3
View File
@@ -63,8 +63,6 @@ pub fn get_tools_list() -> Value {
system::tool_list_symbols(),
system::tool_healthcheck(),
system::tool_get_active_account(),
system::tool_check_update(),
system::tool_update(),
// Utility (8 tools)
utility::tool_check_symbol_data_status(),
utility::tool_get_backtest_history(),
@@ -112,7 +110,6 @@ pub fn get_tools_list() -> Value {
reports::tool_search_reports_by_notes(),
reports::tool_get_reports_by_set_file(),
reports::tool_get_comparable_reports(),
reports::tool_export_deals_csv(),
];
serde_json::json!(tools)
-14
View File
@@ -278,17 +278,3 @@ pub fn tool_get_comparable_reports() -> Value {
}
})
}
pub fn tool_export_deals_csv() -> Value {
json!({
"name": "export_deals_csv",
"description": "Export deals for a report to a CSV file on demand. Deals are stored in the database — use this to get a CSV file for external tools.",
"inputSchema": {
"type": "object",
"properties": {
"report_id": { "type": "string", "description": "Report ID to export (default: latest report)" },
"output_path": { "type": "string", "description": "File path for the CSV output (default: <report_dir>/deals.csv)" }
}
}
})
}
-22
View File
@@ -45,25 +45,3 @@ pub fn tool_get_active_account() -> 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",
"properties": {}
}
})
}
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",
"properties": {}
}
})
}
+90 -50
View File
@@ -7,7 +7,6 @@ use crate::analytics::DealAnalyzer;
use crate::models::deals::Deal;
use crate::models::metrics::Metrics;
use crate::models::Config;
use crate::storage::ReportDb;
// ── Internal helpers ──────────────────────────────────────────────────────────
@@ -31,46 +30,66 @@ fn err_response(msg: impl std::fmt::Display) -> Value {
})
}
/// Resolve a report from args (report_id > report_dir > latest).
/// Returns (deals, metrics, report_dir).
fn resolve_report(args: &Value) -> Result<(Vec<Deal>, Metrics, String)> {
let db = ReportDb::new(&Config::db_path());
db.init()?;
fn load_report_data(report_dir: &str) -> Result<(Vec<Deal>, Metrics)> {
let deals_csv = Path::new(report_dir).join("deals.csv");
let metrics_json = Path::new(report_dir).join("metrics.json");
let entry = if let Some(id) = args.get("report_id").and_then(|v| v.as_str()) {
db.get_by_id(id)?
.ok_or_else(|| anyhow::anyhow!("Report '{}' not found in DB", id))?
} else if let Some(dir) = args.get("report_dir").and_then(|v| v.as_str()) {
db.get_by_report_dir(dir)?
.ok_or_else(|| anyhow::anyhow!(
"No DB entry for report_dir '{}'. This report may predate DB storage.", dir
))?
} else {
db.get_latest()?
.ok_or_else(|| anyhow::anyhow!("No reports in DB. Run a backtest first."))?
};
if !deals_csv.exists() {
return Err(anyhow::anyhow!("deals.csv not found in {}", report_dir));
}
let deals = db.get_deals(&entry.id)?;
let metrics_path = Path::new(&entry.report_dir).join("metrics.json");
let metrics = if metrics_path.exists() {
serde_json::from_str(&fs::read_to_string(&metrics_path)?)?
let deals = read_deals_from_csv(&deals_csv)?;
let metrics = if metrics_json.exists() {
serde_json::from_str(&fs::read_to_string(&metrics_json)?)?
} else {
Metrics::default()
};
Ok((deals, metrics, entry.report_dir))
Ok((deals, metrics))
}
fn prepare_analysis(args: &Value) -> Result<(Vec<Deal>, Metrics, DealAnalyzer, String)> {
let (deals, metrics, report_dir) = resolve_report(args)?;
Ok((deals, metrics, DealAnalyzer::new(), report_dir))
fn prepare_analysis(report_dir: &str) -> Result<(Vec<Deal>, Metrics, DealAnalyzer)> {
let (deals, metrics) = load_report_data(report_dir)?;
Ok((deals, metrics, DealAnalyzer::new()))
}
fn read_deals_from_csv(path: &Path) -> Result<Vec<Deal>> {
let content = fs::read_to_string(path)?;
let mut deals = Vec::new();
let mut lines = content.lines();
let _header = lines.next();
for line in lines {
let parts: Vec<&str> = line.split(',').collect();
if parts.len() >= 12 {
deals.push(Deal {
time: parts[0].to_string(),
deal: parts[1].to_string(),
symbol: parts[2].to_string(),
deal_type: parts[3].to_string(),
entry: parts[4].to_string(),
volume: parts[5].parse().unwrap_or(0.0),
price: parts[6].parse().unwrap_or(0.0),
order: parts[7].to_string(),
commission: parts[8].parse().unwrap_or(0.0),
swap: parts[9].parse().unwrap_or(0.0),
profit: parts[10].parse().unwrap_or(0.0),
balance: parts[11].parse().unwrap_or(0.0),
comment: parts.get(12).unwrap_or(&"").to_string(),
magic: parts.get(13).map(|s| s.to_string()),
});
}
}
Ok(deals)
}
// ── Composite analytics ───────────────────────────────────────────────────────
pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result<Value> {
let (deals, metrics, analyzer, report_dir) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, metrics, analyzer) = prepare_analysis(report_dir)?;
let requested: Option<HashSet<String>> = args.get("analytics")
.and_then(|v| v.as_array())
@@ -92,7 +111,7 @@ pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result<Val
if req("streak_analysis") { result["streak_analysis"] = json!(analyzer.streak_analysis(&deals)); }
if req("concurrent_peak") { result["concurrent_peak"] = json!(analyzer.concurrent_peak(&deals)); }
let analysis_path = Path::new(&report_dir).join("analysis.json");
let analysis_path = Path::new(report_dir).join("analysis.json");
fs::write(&analysis_path, serde_json::to_string_pretty(&result)?)?;
Ok(ok_response(json!({
@@ -104,10 +123,10 @@ pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result<Val
}
pub async fn handle_compare_baseline(_config: &Config, args: &Value) -> Result<Value> {
let (_, _, _, report_dir) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let baseline_path = Path::new("config/baseline.json");
let metrics_path = Path::new(&report_dir).join("metrics.json");
let metrics_path = Path::new(report_dir).join("metrics.json");
if !baseline_path.exists() {
return Ok(ok_response(json!("No baseline.json found in config/")));
@@ -131,78 +150,93 @@ pub async fn handle_compare_baseline(_config: &Config, args: &Value) -> Result<V
// ── Granular analytics handlers ───────────────────────────────────────────────
pub async fn handle_analyze_monthly_pnl(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "monthly_pnl": analyzer.monthly_pnl(&deals) })))
}
pub async fn handle_analyze_drawdown_events(_config: &Config, args: &Value) -> Result<Value> {
let (deals, metrics, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, metrics, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "drawdown_events": analyzer.reconstruct_dd_events(&deals, &metrics) })))
}
pub async fn handle_analyze_top_losses(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = required_str(args, "report_dir")?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "limit": limit, "top_losses": analyzer.top_losses(&deals, limit) })))
}
pub async fn handle_analyze_loss_sequences(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "loss_sequences": analyzer.loss_sequences(&deals) })))
}
pub async fn handle_analyze_position_pairs(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "position_pairs": analyzer.position_pairs(&deals) })))
}
pub async fn handle_analyze_direction_bias(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "direction_bias": analyzer.direction_bias(&deals) })))
}
pub async fn handle_analyze_streaks(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "streak_analysis": analyzer.streak_analysis(&deals) })))
}
pub async fn handle_analyze_concurrent_peak(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "concurrent_peak": analyzer.concurrent_peak(&deals) })))
}
pub async fn handle_analyze_profit_distribution(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "profit_distribution": analyzer.profit_distribution(&deals) })))
}
pub async fn handle_analyze_time_performance(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "time_performance": analyzer.time_performance(&deals) })))
}
pub async fn handle_analyze_hold_time_distribution(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "hold_time_analysis": analyzer.hold_time_analysis(&deals) })))
}
pub async fn handle_analyze_layer_performance(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "layer_performance": analyzer.layer_performance(&deals) })))
}
pub async fn handle_analyze_volume_vs_profit(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "volume_analysis": analyzer.volume_analysis(&deals) })))
}
pub async fn handle_analyze_costs(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "cost_analysis": analyzer.cost_analysis(&deals) })))
}
pub async fn handle_analyze_efficiency(_config: &Config, args: &Value) -> Result<Value> {
let (deals, metrics, analyzer, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, metrics, analyzer) = prepare_analysis(report_dir)?;
Ok(ok_response(json!({ "success": true, "efficiency_analysis": analyzer.efficiency_analysis(&deals, &metrics) })))
}
@@ -229,7 +263,8 @@ fn is_closed_trade(d: &Deal) -> bool {
}
pub async fn handle_list_deals(_config: &Config, args: &Value) -> Result<Value> {
let (deals, _, _, _) = prepare_analysis(args)?;
let report_dir = required_str(args, "report_dir")?;
let (deals, _) = load_report_data(report_dir)?;
let deal_type = args.get("deal_type").and_then(|v| v.as_str());
let min_profit = args.get("min_profit").and_then(|v| v.as_f64());
@@ -264,9 +299,11 @@ pub async fn handle_list_deals(_config: &Config, args: &Value) -> Result<Value>
}
pub async fn handle_search_deals_by_comment(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = required_str(args, "report_dir")?;
let query = required_str(args, "query")?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let (deals, _, _, _) = prepare_analysis(args)?;
let (deals, _) = load_report_data(report_dir)?;
let query_lower = query.to_lowercase();
let mut filtered: Vec<&Deal> = deals.iter()
@@ -285,9 +322,11 @@ pub async fn handle_search_deals_by_comment(_config: &Config, args: &Value) -> R
}
pub async fn handle_search_deals_by_magic(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = required_str(args, "report_dir")?;
let magic = required_str(args, "magic")?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
let (deals, _, _, _) = prepare_analysis(args)?;
let (deals, _) = load_report_data(report_dir)?;
let mut filtered: Vec<&Deal> = deals.iter()
.filter(|d| is_closed_trade(d) && d.magic.as_ref().map(|m| m.contains(magic)).unwrap_or(false))
@@ -304,5 +343,6 @@ pub async fn handle_search_deals_by_magic(_config: &Config, args: &Value) -> Res
})))
}
// suppress unused warning — err_response is available for future handlers
#[allow(dead_code)]
fn _err_response_available() { let _ = err_response(""); }
fn _use_err_response() { let _ = err_response(""); }
+7 -13
View File
@@ -147,12 +147,12 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value>
}));
}
// Date defaulting: current month
// Date defaulting: past complete calendar month
let (from_date, to_date) = {
let f = args.get("from_date").and_then(|v| v.as_str()).unwrap_or("");
let t = args.get("to_date").and_then(|v| v.as_str()).unwrap_or("");
if f.is_empty() || t.is_empty() {
super::current_month()
super::past_complete_month()
} else {
(f.to_string(), t.to_string())
}
@@ -176,7 +176,6 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value>
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),
};
let pipeline = BacktestPipeline::new(config.clone());
@@ -213,13 +212,13 @@ pub async fn handle_run_backtest_only(config: &Config, args: &Value) -> Result<V
handle_run_backtest(config, &args).await
}
pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandler, args: &Value) -> Result<Value> {
pub async fn handle_launch_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"))?;
// Run pre-flight check
let preflight = BacktestPreflight::check(&handler.config, expert);
let preflight = BacktestPreflight::check(config, expert);
// Check account session
if preflight.account.is_none() {
@@ -238,7 +237,7 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle
.unwrap_or("");
let symbol = if requested_symbol.is_empty() {
handler.config.backtest_symbol.clone()
config.backtest_symbol.clone()
.or_else(|| preflight.available_symbols.first().cloned())
.unwrap_or_else(|| "EURUSD".to_string())
} else {
@@ -261,7 +260,7 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle
let f = args.get("from_date").and_then(|v| v.as_str()).unwrap_or("");
let t = args.get("to_date").and_then(|v| v.as_str()).unwrap_or("");
if f.is_empty() || t.is_empty() {
super::current_month()
super::past_complete_month()
} else {
(f.to_string(), t.to_string())
}
@@ -285,14 +284,9 @@ pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandle
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),
};
let pipeline = if let Some(ref callback) = handler.notification_callback {
BacktestPipeline::with_notification_callback(handler.config.clone(), callback.clone())
} else {
BacktestPipeline::new(handler.config.clone())
};
let pipeline = BacktestPipeline::new(config.clone());
let job = pipeline.launch_backtest(params).await?;
Ok(json!({
+6 -54
View File
@@ -2,12 +2,8 @@ use anyhow::Result;
use chrono::Datelike;
use serde_json::{json, Value};
use std::path::Path;
use std::sync::{Arc, OnceLock};
use std::sync::atomic::{AtomicBool, Ordering};
use crate::models::Config;
type NotificationCallback = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
mod system;
mod experts;
mod backtest;
@@ -17,45 +13,23 @@ mod setfiles;
mod reports;
mod utility;
/// Cached result of the background update check.
/// - Not yet initialized: check still in flight (or not spawned yet)
/// - Some(version): a newer version is available
/// - None: already on latest, or GitHub unreachable
pub(crate) static LATEST_VERSION: OnceLock<Option<String>> = OnceLock::new();
static BACKGROUND_CHECK_SPAWNED: AtomicBool = AtomicBool::new(false);
#[derive(Clone)]
#[derive(Debug)]
pub struct ToolHandler {
pub config: Config,
pub notification_callback: Option<NotificationCallback>,
}
impl ToolHandler {
pub fn new(config: Config) -> Self {
Self { config, notification_callback: None }
}
pub fn with_notification_callback(config: Config, callback: NotificationCallback) -> Self {
Self { config, notification_callback: Some(callback) }
Self { config }
}
pub async fn handle(&self, name: &str, args: &Value) -> Result<Value> {
// Spawn a one-shot background update check on the very first tool call of the session.
if !BACKGROUND_CHECK_SPAWNED.swap(true, Ordering::Relaxed) {
tokio::spawn(async {
let result = system::fetch_latest_version().await;
let _ = LATEST_VERSION.set(result);
});
}
match name {
// System handlers
"verify_setup" => system::handle_verify_setup(&self.config).await,
"list_symbols" => system::handle_list_symbols(&self.config).await,
"healthcheck" => system::handle_healthcheck(&self.config, args).await,
"verify_setup" => system::handle_verify_setup(&self.config).await,
"list_symbols" => system::handle_list_symbols(&self.config).await,
"healthcheck" => system::handle_healthcheck(&self.config, args).await,
"get_active_account" => system::handle_get_active_account(&self.config).await,
"check_update" => system::handle_check_update(&self.config).await,
"update" => system::handle_update(&self.config).await,
// Expert/Indicator/Script handlers
"list_experts" => experts::handle_list_experts(&self.config, args).await,
@@ -72,7 +46,7 @@ impl ToolHandler {
"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
"launch_backtest" => backtest::handle_launch_backtest(self, args).await, // Fire-and-forget mode
"launch_backtest" => backtest::handle_launch_backtest(&self.config, args).await, // Fire-and-forget mode
"get_backtest_status" => backtest::handle_get_backtest_status(&self.config, args).await,
"cache_status" => backtest::handle_cache_status(&self.config).await,
"clean_cache" => backtest::handle_clean_cache(&self.config, args).await,
@@ -135,7 +109,6 @@ impl ToolHandler {
"search_reports_by_notes" => reports::handle_search_reports_by_notes(args).await,
"get_reports_by_set_file" => reports::handle_get_reports_by_set_file(args).await,
"get_comparable_reports" => reports::handle_get_comparable_reports(args).await,
"export_deals_csv" => reports::handle_export_deals_csv(&self.config, args).await,
// Utility handlers
"check_symbol_data_status" => utility::handle_check_symbol_data_status(&self.config, args).await,
@@ -192,24 +165,3 @@ pub(crate) fn past_complete_month() -> (String, String) {
last_of_prev.format("%Y.%m.%d").to_string(),
)
}
pub(crate) fn current_month() -> (String, String) {
let now = chrono::Utc::now();
let first_of_month = chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
.unwrap_or_else(|| chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
let last_of_month = if now.month() == 12 {
chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1)
.unwrap_or(first_of_month)
.pred_opt()
.unwrap_or(first_of_month)
} else {
chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1)
.unwrap_or(first_of_month)
.pred_opt()
.unwrap_or(first_of_month)
};
(
first_of_month.format("%Y.%m.%d").to_string(),
last_of_month.format("%Y.%m.%d").to_string(),
)
}
-43
View File
@@ -3,7 +3,6 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
use serde_json::{json, Value};
use std::fs;
use std::path::Path;
use crate::analytics::ReportExtractor;
use crate::models::Config;
use crate::storage::{ReportDb, ReportFilters};
@@ -849,45 +848,3 @@ pub async fn handle_get_comparable_reports(args: &Value) -> Result<Value> {
"isError": false
}))
}
pub async fn handle_export_deals_csv(_config: &Config, args: &Value) -> Result<Value> {
let db = ReportDb::new(&Config::db_path());
if let Err(e) = db.init() {
return Ok(json!({ "content": [{ "type": "text", "text": format!("DB error: {}", e) }], "isError": true }));
}
let report_id_opt = args.get("report_id").and_then(|v| v.as_str());
let entry = match report_id_opt {
Some(id) => db.get_by_id(id)?,
None => db.get_latest()?,
};
let entry = match entry {
Some(e) => e,
None => return Ok(json!({ "content": [{ "type": "text", "text": "No report found" }], "isError": true })),
};
let deals = db.get_deals(&entry.id)?;
if deals.is_empty() {
return Ok(json!({ "content": [{ "type": "text", "text": format!("No deals stored for report {}", entry.id) }], "isError": false }));
}
let output_path = match args.get("output_path").and_then(|v| v.as_str()) {
Some(p) => std::path::PathBuf::from(p),
None => Path::new(&entry.report_dir).join("deals.csv"),
};
let extractor = ReportExtractor::new();
extractor.write_deals_to_csv(&deals, &output_path)
.map_err(|e| anyhow::anyhow!("Failed to write CSV: {}", e))?;
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"report_id": entry.id,
"deals_count": deals.len(),
"output_path": output_path.to_string_lossy(),
}).to_string() }],
"isError": false
}))
}
-172
View File
@@ -3,178 +3,6 @@ use serde_json::{json, Value};
use std::path::Path;
use crate::models::Config;
// ── Update helpers ────────────────────────────────────────────────────────────
fn platform_tag() -> &'static str {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))] return "macos-aarch64";
#[cfg(all(target_os = "macos", target_arch = "x86_64"))] return "macos-x86_64";
#[cfg(all(target_os = "linux", target_arch = "x86_64"))] return "linux-x86_64";
#[cfg(not(any(
all(target_os = "macos", target_arch = "aarch64"),
all(target_os = "macos", target_arch = "x86_64"),
all(target_os = "linux", target_arch = "x86_64"),
)))] return "unsupported";
}
fn semver_newer(latest: &str, current: &str) -> bool {
let parse = |s: &str| -> (u32, u32, u32) {
let mut p = s.trim_start_matches('v').splitn(3, '.');
let ma = p.next().and_then(|x| x.parse().ok()).unwrap_or(0);
let mi = p.next().and_then(|x| x.parse().ok()).unwrap_or(0);
let pa = p.next().and_then(|x| x.parse().ok()).unwrap_or(0);
(ma, mi, pa)
};
parse(latest) > parse(current)
}
/// Fetch the latest release tag from GitHub API (5 s timeout via curl).
/// Returns the version string without the leading "v", or None on failure.
pub(super) async fn fetch_latest_version() -> Option<String> {
let output = tokio::process::Command::new("curl")
.args([
"-sf", "--max-time", "5",
"-H", "Accept: application/vnd.github.v3+json",
"-H", "User-Agent: mt5-quant-updater",
"https://api.github.com/repos/masdevid/mt5-quant/releases/latest",
])
.output()
.await
.ok()?;
if !output.status.success() { return None; }
let body: Value = serde_json::from_slice(&output.stdout).ok()?;
body.get("tag_name")
.and_then(|v| v.as_str())
.map(|s| s.trim_start_matches('v').to_string())
}
fn ok_response(data: Value) -> Value {
json!({ "content": [{ "type": "text", "text": data.to_string() }], "isError": false })
}
fn err_response(msg: impl std::fmt::Display) -> Value {
json!({ "content": [{ "type": "text", "text": msg.to_string() }], "isError": true })
}
// ── Update tool handlers ──────────────────────────────────────────────────────
pub async fn handle_check_update(_config: &Config) -> Result<Value> {
let current = env!("CARGO_PKG_VERSION");
// Use cached background-check result if available; otherwise fetch now.
let latest_opt = match super::LATEST_VERSION.get() {
Some(v) => v.clone(),
None => fetch_latest_version().await,
};
let Some(latest) = latest_opt else {
return Ok(ok_response(json!({
"current_version": current,
"update_available": false,
"error": "Could not reach GitHub API — check network connectivity",
})));
};
let update_available = semver_newer(&latest, current);
Ok(ok_response(json!({
"current_version": current,
"latest_version": latest,
"update_available": update_available,
"hint": if update_available {
format!("Run the `update` tool to install v{latest}")
} else {
"You are on the latest version".to_string()
},
})))
}
pub async fn handle_update(_config: &Config) -> Result<Value> {
let current = env!("CARGO_PKG_VERSION");
let latest = match super::LATEST_VERSION.get().and_then(|v| v.as_deref()) {
Some(v) => v.to_string(),
None => match fetch_latest_version().await {
Some(v) => v,
None => return Ok(err_response(
r#"{"success":false,"error":"Could not determine latest version — check network"}"#
)),
},
};
if !semver_newer(&latest, current) {
return Ok(ok_response(json!({
"up_to_date": true,
"version": current,
})));
}
let tag = platform_tag();
if tag == "unsupported" {
return Ok(err_response(
r#"{"success":false,"error":"Auto-update not supported on this platform — build from source"}"#
));
}
let url = format!(
"https://github.com/masdevid/mt5-quant/releases/download/v{latest}/mcp-mt5-quant-{tag}.tar.gz"
);
// Download tarball to a temp file
let tmp_tar = tempfile::NamedTempFile::new()?;
let dl = tokio::process::Command::new("curl")
.args(["-sfL", "--max-time", "120",
"-o", tmp_tar.path().to_str().unwrap_or_default(),
&url])
.status()
.await?;
if !dl.success() {
return Ok(err_response(format!(
r#"{{"success":false,"error":"Download failed","url":"{}"}}"#, url
)));
}
// Extract binary (tarball root dir is mcp-mt5-quant-{platform}/)
let tmp_dir = tempfile::tempdir()?;
let extract = tokio::process::Command::new("tar")
.args(["-xzf", tmp_tar.path().to_str().unwrap_or_default(),
"-C", tmp_dir.path().to_str().unwrap_or_default(),
"--strip-components=1"])
.status()
.await?;
if !extract.success() {
return Ok(err_response(r#"{"success":false,"error":"Failed to extract archive"}"#));
}
let new_bin = tmp_dir.path().join("mt5-quant");
if !new_bin.exists() {
return Ok(err_response(r#"{"success":false,"error":"Binary not found in archive"}"#));
}
// Atomic replace: write to sibling .tmp, then rename (safe on same FS)
let current_exe = std::env::current_exe()?;
let tmp_dest = current_exe.with_extension("update_tmp");
std::fs::copy(&new_bin, &tmp_dest)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp_dest, std::fs::Permissions::from_mode(0o755))?;
}
std::fs::rename(&tmp_dest, &current_exe)?;
Ok(ok_response(json!({
"success": true,
"previous_version": current,
"updated_to": latest,
"binary": current_exe.to_string_lossy(),
"hint": format!("Updated to v{latest}. Restart the MCP connection to load the new binary."),
})))
}
pub async fn handle_verify_setup(config: &Config) -> Result<Value> {
let mut checks = serde_json::Map::new();
let mut all_ok = true;
-193
View File
@@ -1,193 +0,0 @@
#!/usr/bin/env python3
"""MCP test harness: run backtest then exercise all granular analysis tools."""
import json
import subprocess
import sys
import time
import threading
BINARY = "/Users/masdevid/.cargo/bin/mt5-quant"
TIMEOUT = 1200 # 20 min for backtest
def send(proc, msg):
line = json.dumps(msg) + "\n"
proc.stdin.write(line.encode())
proc.stdin.flush()
def recv(proc, timeout=30):
"""Read one JSON-RPC response line."""
proc.stdout.readline() # skip blank / notification lines
deadline = time.time() + timeout
while time.time() < deadline:
line = proc.stdout.readline().decode("utf-8", errors="replace").strip()
if not line:
continue
try:
return json.loads(line)
except json.JSONDecodeError:
pass # skip non-JSON lines (logs etc)
return None
def recv_with_id(proc, expected_id, timeout=1200):
"""Read lines until we get a response with the expected id."""
deadline = time.time() + timeout
while time.time() < deadline:
line = proc.stdout.readline().decode("utf-8", errors="replace").strip()
if not line:
continue
try:
msg = json.loads(line)
if msg.get("id") == expected_id:
return msg
except json.JSONDecodeError:
pass
return None
def tool_call(proc, call_id, name, arguments=None):
send(proc, {
"jsonrpc": "2.0",
"id": call_id,
"method": "tools/call",
"params": {"name": name, "arguments": arguments or {}}
})
def extract_text(resp):
if not resp:
return "NO RESPONSE"
result = resp.get("result", {})
content = result.get("content", [])
if content:
text = content[0].get("text", "")
try:
parsed = json.loads(text)
return json.dumps(parsed, indent=2)[:1500]
except Exception:
return text[:1500]
if "error" in resp:
return f"ERROR: {resp['error']}"
return str(resp)[:500]
def ok(resp):
if not resp:
return False
result = resp.get("result", {})
content = result.get("content", [])
is_error = result.get("isError", True)
if is_error:
return False
if content:
text = content[0].get("text", "")
try:
parsed = json.loads(text)
return parsed.get("success", True) is not False
except Exception:
return True
return not is_error
# ── Start server ──────────────────────────────────────────────────────────────
print("Starting mt5-quant MCP server...")
proc = subprocess.Popen(
[BINARY],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
)
# ── Handshake ─────────────────────────────────────────────────────────────────
send(proc, {
"jsonrpc": "2.0", "id": 0,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-harness", "version": "1.0"}
}
})
init_resp = recv_with_id(proc, 0, timeout=10)
if not init_resp:
print("FATAL: no initialize response")
proc.terminate()
sys.exit(1)
print("Initialized OK")
send(proc, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
# ── Run backtest ──────────────────────────────────────────────────────────────
print("\n=== run_backtest (DPS21, XAUUSD, M5, OHLC, skip_compile) ===")
tool_call(proc, 1, "run_backtest", {
"expert": "DPS21",
"symbol": "XAUUSDc",
"timeframe": "M5",
"model": 1, # OHLC faster
"skip_compile": True,
"timeout": 900,
"startup_delay_secs": 15,
})
resp = recv_with_id(proc, 1, timeout=TIMEOUT)
print(extract_text(resp))
backtest_ok = ok(resp)
print(f"backtest_ok={backtest_ok}")
if not backtest_ok:
print("Backtest failed aborting analysis tests")
proc.terminate()
sys.exit(1)
# ── Full analysis ─────────────────────────────────────────────────────────────
print("\n=== analyze_report (latest, all analytics) ===")
tool_call(proc, 2, "analyze_report", {})
resp = recv_with_id(proc, 2, timeout=60)
print(extract_text(resp))
print(f"analyze_report ok={ok(resp)}")
# ── Granular analytics ────────────────────────────────────────────────────────
granular = [
("analyze_monthly_pnl", {}),
("analyze_drawdown_events", {}),
("analyze_top_losses", {"limit": 5}),
("analyze_loss_sequences", {}),
("analyze_position_pairs", {}),
("analyze_direction_bias", {}),
("analyze_streaks", {}),
("analyze_concurrent_peak", {}),
("analyze_profit_distribution", {}),
("analyze_time_performance", {}),
("analyze_hold_time_distribution",{}),
("analyze_layer_performance", {}),
("analyze_volume_vs_profit", {}),
("analyze_costs", {}),
("analyze_efficiency", {}),
("list_deals", {"limit": 5}),
("search_deals_by_comment", {"query": "tp"}),
("search_deals_by_magic", {"magic": "0"}),
]
results = {}
for i, (tool, args) in enumerate(granular, start=10):
print(f"\n=== {tool} ===")
tool_call(proc, i, tool, args)
resp = recv_with_id(proc, i, timeout=30)
status = "OK" if ok(resp) else "FAIL"
results[tool] = status
print(f" {status}")
if status == "FAIL":
print(" " + extract_text(resp)[:400])
# ── Summary ───────────────────────────────────────────────────────────────────
print("\n\n========== SUMMARY ==========")
passed = [t for t, s in results.items() if s == "OK"]
failed = [t for t, s in results.items() if s != "OK"]
print(f"PASSED ({len(passed)}): {', '.join(passed)}")
if failed:
print(f"FAILED ({len(failed)}): {', '.join(failed)}")
else:
print("All granular analytics PASSED")
proc.terminate()