Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12fba8ad4b | |||
| b6171d0c56 | |||
| 05214ff34b | |||
| 498126dfd4 | |||
| 147da213d6 | |||
| 804c1f8808 | |||
| f8b6a11bb8 | |||
| e82bb5466b | |||
| 50ea45f8cd | |||
| 4bc5ca22e9 | |||
| e44345a05f | |||
| 01514623c9 | |||
| bce0323469 | |||
| ccc9e7e4d8 | |||
| e1adcad45c | |||
| b97011d5b6 | |||
| 41344b14b8 | |||
| 436566e019 | |||
| 74c3fdb87e | |||
| 55e0822051 | |||
| 5366b45c36 | |||
| 96ba882a95 | |||
| b9729b7121 | |||
| e7d2ec5a47 | |||
| 7620cb3c04 | |||
| 9a8594426a | |||
| 061e36d339 | |||
| cc4b702176 | |||
| e58316e70e | |||
| a1c5d328b3 | |||
| 6f773d6f09 | |||
| c895eb9b33 | |||
| e0b1356afa | |||
| 23a0b7a375 | |||
| ac5ec453d3 | |||
| 9f6c67d0ef |
@@ -1,114 +0,0 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
+2
-4
@@ -20,14 +20,12 @@ config/baseline.json
|
||||
config/backtest_history.json
|
||||
|
||||
# Claude Code (personal, not for repo)
|
||||
CLAUDE.md
|
||||
/CLAUDE.md
|
||||
.claude/
|
||||
|
||||
# Windsurf (local workflows, not for repo)
|
||||
.windsurf/
|
||||
|
||||
# GitHub Actions (local workflows, not for repo)
|
||||
.github/workflows/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
---
|
||||
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
|
||||
```
|
||||
@@ -1,5 +1,35 @@
|
||||
# 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
@@ -481,7 +481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mt5-quant"
|
||||
version = "1.31.2"
|
||||
version = "1.32.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+12
-2
@@ -1,9 +1,19 @@
|
||||
[package]
|
||||
name = "mt5-quant"
|
||||
version = "1.31.2"
|
||||
version = "1.32.3"
|
||||
edition = "2021"
|
||||
description = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP"
|
||||
description = "MCP server for MT5 strategy development on macOS/Linux"
|
||||
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"
|
||||
|
||||
[package.metadata]
|
||||
maintenance = { status = "actively-developed" }
|
||||
|
||||
[[bin]]
|
||||
name = "mt5-quant"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MT5-Quant
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
```
|
||||
You: "Backtest MyEA Jan-Mar, what caused the February drawdown?"
|
||||
@@ -13,26 +13,57 @@ Claude: [compile → clean → backtest → analyze 1,847 deals]
|
||||
|
||||
## Why MT5-Quant
|
||||
|
||||
| | 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 | ❌ | ❌ |
|
||||
**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 | 200–500ms |
|
||||
| **Deploy** | Single binary | venv + pip/npm |
|
||||
| **Memory** | <50MB, no GC pauses | Unpredictable spikes |
|
||||
| **Safety** | Compile-time guaranteed | Runtime exceptions |
|
||||
|
||||
**Why this matters for trading:**
|
||||
- **No garbage collection** — Process 100k+ deal rows without GC pauses during live analysis
|
||||
- **Async via Tokio** — Handle multiple tool calls concurrently (poll status while streaming logs)
|
||||
- **Cross-platform** — Same source compiles to native macOS arm64 and Linux x86_64
|
||||
- **Type-safe pipelines** — MQL5 compilation, Wine path handling, SQLite queries: all checked at compile time
|
||||
|
||||
## Quick Install
|
||||
|
||||
### 1. Download & Setup
|
||||
### Option 1: Pre-built Binary (Recommended)
|
||||
|
||||
```bash
|
||||
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
|
||||
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
|
||||
tar -xzf mt5.tar.gz
|
||||
bash scripts/setup.sh
|
||||
```
|
||||
|
||||
### 2. Register MCP Server
|
||||
### Option 2: Cargo Install (if you have Rust)
|
||||
|
||||
```bash
|
||||
cargo install mt5-quant
|
||||
mt5-quant --help
|
||||
```
|
||||
|
||||
Compiles from source. Takes 2–5 minutes. Requires Rust 1.70+.
|
||||
|
||||
### Register MCP Server
|
||||
|
||||
| Platform | Command / Config | Docs |
|
||||
|----------|------------------|------|
|
||||
@@ -40,7 +71,7 @@ bash scripts/setup.sh
|
||||
| **Windsurf** | Edit `~/.codeium/windsurf/mcp_config.json` | [WINDSURF.md →](docs/WINDSURF.md) |
|
||||
| **Cursor** | Edit `~/.cursor/mcp.json` or use Settings → MCP | [CURSOR.md →](docs/CURSOR.md) |
|
||||
| **VS Code** | Edit `.vscode/mcp.json` or run `MCP: Add Server` | [VSCODE.md →](docs/VSCODE.md) |
|
||||
| **Antigravity** | Agent Panel → ... → MCP Servers → Edit configuration | [ANTIGRAVITY.md →](docs/ANTIGRAVITY.md) |
|
||||
| **Claude Desktop** | Agent Panel → ... → MCP Servers → Edit configuration | [CLAUDE.md →](docs/CLAUDE.md) |
|
||||
|
||||
> **Note:** Use absolute paths like `/Users/name/mt5-quant/mt5-quant` or `$(pwd)/mt5-quant`, not relative paths like `./mt5-quant`.
|
||||
|
||||
@@ -60,14 +91,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 |
|
||||
| [ANTIGRAVITY.md](docs/ANTIGRAVITY.md) | Antigravity IDE setup |
|
||||
| [CLAUDE.md](docs/CLAUDE.md) | Claude Desktop setup |
|
||||
| [CONFIG.md](docs/CONFIG.md) | Configuration reference |
|
||||
| [TOOLS.md](docs/MCP_TOOLS.md) | All 87 tools documented |
|
||||
| [TOOLS.md](docs/MCP_TOOLS.md) | All 89 tools documented |
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design and internals |
|
||||
| [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Common issues |
|
||||
| [REMOTE_AGENTS.md](docs/REMOTE_AGENTS.md) | Linux optimization agents |
|
||||
|
||||
## MCP Tools (87)
|
||||
## MCP Tools (89)
|
||||
|
||||
### Core workflow
|
||||
|
||||
@@ -80,6 +111,7 @@ 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 |
|
||||
@@ -87,6 +119,7 @@ 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)
|
||||
|
||||
@@ -143,6 +176,7 @@ 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
|
||||
|
||||
@@ -152,7 +186,6 @@ 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
|
||||
|
||||
@@ -183,6 +216,8 @@ 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
|
||||
|
||||
@@ -243,6 +278,37 @@ 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
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
# 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)
|
||||
+37
-67
@@ -1,14 +1,12 @@
|
||||
# Architecture Deep Dive
|
||||
# Architecture
|
||||
|
||||
## Design Philosophy
|
||||
## Design Principles
|
||||
|
||||
**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.
|
||||
**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.
|
||||
|
||||
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.
|
||||
**Pipeline idempotency.** MT5 caches aggressively (`.ex5` binaries, `.set` files, `terminal.ini` flags). The pipeline invalidates all cache before every run to prevent stale results.
|
||||
|
||||
**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.
|
||||
**Background isolation.** Genetic optimizations run 2-6 hours. The `nohup + disown` pattern prevents corruption if the parent process (SSH, Claude runner) is killed.
|
||||
|
||||
---
|
||||
|
||||
@@ -25,24 +23,26 @@ 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.csv
|
||||
│ │ ├── extract.rs # HTML/XML report parser → metrics.json (deals → DB)
|
||||
│ │ └── 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, 43 tools)
|
||||
│ ├── definitions/ # Tool schemas (9 domain modules, 90 tools)
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── analytics.rs # 9 analysis tools
|
||||
│ │ ├── backtest.rs # 4 backtest tools
|
||||
│ │ ├── analytics.rs # 19 analysis tools (DB-backed)
|
||||
│ │ ├── backtest.rs # 7 backtest tools
|
||||
│ │ ├── baseline.rs # 1 baseline tool
|
||||
│ │ ├── experts.rs # 4 EA/indicator/script tools
|
||||
│ │ ├── experts.rs # 9 EA/indicator/script tools
|
||||
│ │ ├── optimization.rs # 4 optimization tools
|
||||
│ │ ├── reports.rs # 11 report management tools
|
||||
│ │ ├── reports.rs # 20 report management tools
|
||||
│ │ ├── setfiles.rs # 8 .set file tools
|
||||
│ │ └── system.rs # 3 system tools
|
||||
│ │ └── system.rs # 6 system tools
|
||||
│ └── handlers/ # Tool dispatch (9 domain modules)
|
||||
│ ├── mod.rs
|
||||
│ ├── analysis.rs
|
||||
@@ -59,11 +59,6 @@ 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)
|
||||
@@ -152,17 +147,26 @@ MT5 runs in headless mode, writes the report, and exits.
|
||||
|
||||
---
|
||||
|
||||
### Stage 4: EXTRACT
|
||||
### Stage 4: EXTRACT + STORE
|
||||
|
||||
Single HTML/XML parse pass that produces three artifacts:
|
||||
Single HTML/XML parse pass. Deals go directly into the SQLite database; the raw report file is deleted afterwards.
|
||||
|
||||
```rust
|
||||
// src/analytics/extract.rs
|
||||
let extractor = ReportExtractor::new();
|
||||
let result = extractor.extract(&report_path, &output_dir)?;
|
||||
// → metrics.json (aggregate summary)
|
||||
// → deals.csv (all deals, 13 columns)
|
||||
// → deals.json (same data, JSON)
|
||||
// → 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)
|
||||
```
|
||||
|
||||
**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.
|
||||
@@ -182,12 +186,13 @@ if ext == "xml" || path.ends_with(".htm.xml") {
|
||||
}
|
||||
```
|
||||
|
||||
**Deal columns (13):**
|
||||
**Deal columns (stored in DB):**
|
||||
```
|
||||
Time | Type | Direction | Volume | Price | S/L | T/P | Profit | Balance | Comment | Order | Magic | Entry
|
||||
time | deal | symbol | deal_type | entry | volume | price | order_id
|
||||
commission | swap | profit | balance | comment | magic
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
@@ -231,15 +236,6 @@ 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
|
||||
|
||||
@@ -416,7 +412,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 `analytics/analyze.py` — no other code changes needed.
|
||||
- Custom comment patterns can be supported by adding a new entry to `PROFILES` in `src/analytics/analyze.rs`.
|
||||
|
||||
**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).
|
||||
@@ -425,34 +421,8 @@ Then set `DISPLAY=:99` in MT5-Quant's environment config.
|
||||
|
||||
## Claude Code Integration
|
||||
|
||||
`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:
|
||||
`setup.sh` generates `config/CLAUDE.template.md` — a project-level template encoding:
|
||||
- MT5-Quant tool names and when to use them
|
||||
- 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.
|
||||
- Baseline tracking policy (compare to `baseline.json` before calling improvements)
|
||||
- Symbol name reminders (`XAUUSD.cent` ≠ `XAUUSD`)
|
||||
- Backtest constraints (model 0, UTF-16LE .set files)
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
# 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
@@ -6,12 +6,12 @@
|
||||
|
||||
```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
|
||||
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
|
||||
tar -xzf mt5.tar.gz
|
||||
|
||||
# Linux (x64)
|
||||
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
|
||||
tar -xzf mt5-quant.tar.gz
|
||||
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
|
||||
tar -xzf mt5.tar.gz
|
||||
```
|
||||
|
||||
### Option 2: Build from Source
|
||||
@@ -29,7 +29,7 @@ cargo build --release
|
||||
3. Click **Add Custom MCP**
|
||||
4. Enter:
|
||||
- **Name**: `mt5-quant`
|
||||
- **Command**: `~/.local/bin/mt5-quant`
|
||||
- **Command**: `/path/to/mt5-quant/mcp-server/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": "~/.local/bin/mt5-quant"
|
||||
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ cat > ~/.cursor/mcp.json << 'EOF'
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"type": "stdio",
|
||||
"command": "/path/to/mt5-quant"
|
||||
"command": "/path/to/mt5-quant/mcp-server/bin/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 --help`
|
||||
3. Test binary: `/path/to/mt5-quant/mcp-server/bin/mt5-quant --help`
|
||||
4. View MCP logs: Output panel → select "MCP" from dropdown
|
||||
|
||||
### Config interpolation
|
||||
@@ -103,7 +103,7 @@ Cursor supports variable substitution in `mcp.json`:
|
||||
{
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"command": "${userHome}/bin/mt5-quant",
|
||||
"command": "${userHome}/mt5-quant/mcp-server/bin/mt5-quant",
|
||||
"args": ["--config", "${workspaceFolder}/config.yaml"]
|
||||
}
|
||||
}
|
||||
|
||||
+1761
-17
File diff suppressed because it is too large
Load Diff
+14
-15
@@ -6,19 +6,19 @@
|
||||
|
||||
```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
|
||||
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
|
||||
tar -xzf mt5.tar.gz
|
||||
|
||||
# Linux (x64)
|
||||
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
|
||||
tar -xzf mt5-quant.tar.gz
|
||||
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
|
||||
tar -xzf mt5.tar.gz
|
||||
```
|
||||
|
||||
### Option B: Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/masdevid/mt5-mcp
|
||||
cd mt5-mcp
|
||||
git clone https://github.com/masdevid/mt5-quant
|
||||
cd mt5-quant
|
||||
bash scripts/build-rust.sh
|
||||
```
|
||||
|
||||
@@ -82,6 +82,7 @@ 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:
|
||||
@@ -96,10 +97,8 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"io.github.masdevid/mt5-quant": {
|
||||
"command": "~/.local/bin/mt5-quant",
|
||||
"disabled": false,
|
||||
"registry": "io.github.masdevid/mt5-quant"
|
||||
"mt5-quant": {
|
||||
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +112,7 @@ Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
|
||||
{
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"command": "~/.local/bin/mt5-quant"
|
||||
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +128,7 @@ Add to `.vscode/mcp.json` in your workspace:
|
||||
{
|
||||
"servers": {
|
||||
"mt5-quant": {
|
||||
"command": "/path/to/mt5-quant"
|
||||
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,7 +136,7 @@ Add to `.vscode/mcp.json` in your workspace:
|
||||
|
||||
Or run `MCP: Add Server` from Command Palette.
|
||||
|
||||
### Antigravity
|
||||
### Claude Desktop
|
||||
|
||||
1. Open Agent panel → Click "..." menu → MCP Servers
|
||||
2. Click "Manage MCP Servers"
|
||||
@@ -153,7 +152,7 @@ Or run `MCP: Add Server` from Command Palette.
|
||||
}
|
||||
}
|
||||
```
|
||||
5. Reload Antigravity to apply changes
|
||||
5. Restart Claude Desktop to apply changes
|
||||
|
||||
## 5. Verify Setup
|
||||
|
||||
@@ -190,4 +189,4 @@ The AI will:
|
||||
|
||||
---
|
||||
|
||||
**Next:** See [TOOLS.md](TOOLS.md) for all 43 available tools.
|
||||
**Next:** See [TOOLS.md](TOOLS.md) for all 89 available tools.
|
||||
|
||||
@@ -82,6 +82,23 @@ 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
@@ -6,12 +6,12 @@
|
||||
|
||||
```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
|
||||
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
|
||||
tar -xzf mt5.tar.gz
|
||||
|
||||
# Linux (x64)
|
||||
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
|
||||
tar -xzf mt5-quant.tar.gz
|
||||
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
|
||||
tar -xzf mt5.tar.gz
|
||||
```
|
||||
|
||||
### Option 2: Build from Source
|
||||
@@ -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: `~/.local/bin/mt5-quant`
|
||||
5. Enter command: `/path/to/mt5-quant/mcp-server/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": "~/.local/bin/mt5-quant"
|
||||
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ cat > .vscode/mcp.json << 'EOF'
|
||||
{
|
||||
"servers": {
|
||||
"mt5-quant": {
|
||||
"command": "~/.local/bin/mt5-quant"
|
||||
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@ EOF
|
||||
### Method 3: VS Code CLI
|
||||
|
||||
```bash
|
||||
code --add-mcp '{"name":"mt5-quant","command":"~/.local/bin/mt5-quant"}'
|
||||
code --add-mcp '{"name":"mt5-quant","command":"/path/to/mt5-quant/mcp-server/bin/mt5-quant"}'
|
||||
```
|
||||
|
||||
## Verify Setup
|
||||
@@ -115,7 +115,7 @@ Add to `.devcontainer/devcontainer.json`:
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"mt5-quant": {
|
||||
"command": "/path/to/mt5-quant"
|
||||
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
-10
@@ -6,12 +6,12 @@
|
||||
|
||||
```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
|
||||
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
|
||||
tar -xzf mt5.tar.gz
|
||||
|
||||
# Linux (x64)
|
||||
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
|
||||
tar -xzf mt5-quant.tar.gz
|
||||
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
|
||||
tar -xzf mt5.tar.gz
|
||||
```
|
||||
|
||||
### Option 2: Build from Source
|
||||
@@ -27,10 +27,8 @@ Edit `~/.codeium/windsurf/mcp_config.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"io.github.masdevid/mt5-quant": {
|
||||
"command": "~/.local/bin/mt5-quant",
|
||||
"disabled": false,
|
||||
"registry": "io.github.masdevid/mt5-quant"
|
||||
"mt5-quant": {
|
||||
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +38,7 @@ Or use the automated setup:
|
||||
|
||||
```bash
|
||||
# Install binary to standard location
|
||||
cp target/release/mt5-quant ~/.local/bin/
|
||||
cp mcp-server/bin/mt5-quant ~/.local/bin/
|
||||
|
||||
# Then configure
|
||||
bash scripts/setup.sh
|
||||
@@ -98,7 +96,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: `./target/release/mt5-quant --help`
|
||||
3. Test executable manually: `./mcp-server/bin/mt5-quant --help`
|
||||
|
||||
### Config not found
|
||||
Set `MT5_MCP_HOME` environment variable or ensure config is at default location:
|
||||
|
||||
+30
-23
@@ -1,8 +1,11 @@
|
||||
#!/bin/bash
|
||||
# Build release binaries for distribution
|
||||
# Creates: dist/mt5-quant-{platform}.tar.gz
|
||||
#!/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
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
@@ -10,16 +13,17 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
VERSION=$(grep -E '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
|
||||
echo "=== Building MCP-MT5-Quant v${VERSION} ==="
|
||||
echo "=== Building mt5-quant v${VERSION} ==="
|
||||
echo ""
|
||||
|
||||
# Clean previous builds
|
||||
rm -rf "$PROJECT_ROOT/dist"
|
||||
mkdir -p "$PROJECT_ROOT/dist"
|
||||
|
||||
# Build current platform
|
||||
echo "Building for current platform..."
|
||||
# Build release binary
|
||||
echo "Building release binary..."
|
||||
RUSTFLAGS="-D warnings" cargo build --release
|
||||
echo ""
|
||||
|
||||
# Detect platform
|
||||
UNAME=$(uname -s)
|
||||
@@ -30,43 +34,46 @@ if [[ "$UNAME" == "Darwin" ]]; then
|
||||
elif [[ "$UNAME" == "Linux" ]]; then
|
||||
PLATFORM="linux-${ARCH}"
|
||||
else
|
||||
PLATFORM="unknown"
|
||||
PLATFORM="unknown-${ARCH}"
|
||||
fi
|
||||
|
||||
PACKAGE_NAME="mcp-mt5-quant-${PLATFORM}"
|
||||
PACKAGE_DIR="$PROJECT_ROOT/dist/${PACKAGE_NAME}"
|
||||
|
||||
echo "Packaging for ${PLATFORM}..."
|
||||
mkdir -p "$PACKAGE_DIR"
|
||||
mkdir -p "$PACKAGE_DIR/docs"
|
||||
|
||||
# Copy binary
|
||||
# Binary
|
||||
cp "$PROJECT_ROOT/target/release/mt5-quant" "$PACKAGE_DIR/"
|
||||
chmod +x "$PACKAGE_DIR/mt5-quant"
|
||||
|
||||
# Copy config template
|
||||
# Config template
|
||||
mkdir -p "$PACKAGE_DIR/config"
|
||||
cp "$PROJECT_ROOT/config/mt5-quant.example.yaml" "$PACKAGE_DIR/config/"
|
||||
|
||||
# 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/"
|
||||
# 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/"
|
||||
|
||||
# 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 "Binary: mt5-quant"
|
||||
echo "Size: $(du -h "${PACKAGE_NAME}.tar.gz" | cut -f1)"
|
||||
echo "Package : dist/${PACKAGE_NAME}.tar.gz"
|
||||
echo "Size : $(du -h "${PACKAGE_NAME}.tar.gz" | cut -f1)"
|
||||
echo ""
|
||||
echo "Contents:"
|
||||
tar -tzf "${PACKAGE_NAME}.tar.gz" | head -10
|
||||
tar -tzf "${PACKAGE_NAME}.tar.gz"
|
||||
echo ""
|
||||
echo "To install:"
|
||||
echo "Install:"
|
||||
echo " tar -xzf ${PACKAGE_NAME}.tar.gz"
|
||||
echo " cd ${PACKAGE_NAME}"
|
||||
echo " sudo cp mt5-quant /usr/local/bin/"
|
||||
echo " sudo cp ${PACKAGE_NAME}/mt5-quant /usr/local/bin/"
|
||||
echo " mt5-quant --help"
|
||||
|
||||
+8
-15
@@ -1,32 +1,25 @@
|
||||
#!/bin/bash
|
||||
# Build MT5-Quant Rust MCP Server
|
||||
#!/usr/bin/env bash
|
||||
# Build MT5-Quant release binary
|
||||
# Output: target/release/mt5-quant
|
||||
|
||||
set -e
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== MT5-Quant Rust Build ==="
|
||||
echo "Project root: $PROJECT_ROOT"
|
||||
echo "=== MT5-Quant build ==="
|
||||
echo "Root: $PROJECT_ROOT"
|
||||
echo ""
|
||||
|
||||
echo "Building release binary..."
|
||||
cargo build --release
|
||||
|
||||
echo ""
|
||||
echo "=== Build Complete ==="
|
||||
echo "=== Done ==="
|
||||
echo ""
|
||||
echo "Executable location:"
|
||||
ls -lh "$PROJECT_ROOT/target/release/mt5-quant"
|
||||
|
||||
echo ""
|
||||
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 "Run: ./target/release/mt5-quant --help"
|
||||
echo "Install: cargo install --path . --force"
|
||||
echo ""
|
||||
|
||||
+76
-38
@@ -7,6 +7,7 @@
|
||||
# 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")/.."
|
||||
@@ -18,44 +19,54 @@ warn() { echo -e "${YELLOW}⚠ $*${NC}"; }
|
||||
die() { echo -e "${RED}✗ $*${NC}" >&2; exit 1; }
|
||||
hr() { echo -e "${BLUE}────────────────────────────────────────${NC}"; }
|
||||
|
||||
# ── Prerequisites ─────────────────────────────────────────────────────────────
|
||||
# ── Argument parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
command -v cargo >/dev/null 2>&1 || die "cargo not found"
|
||||
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 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"; 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]" ;;
|
||||
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]" ;;
|
||||
esac
|
||||
|
||||
# Validate semver
|
||||
[[ "$new" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "Invalid version: $new"
|
||||
|
||||
hr
|
||||
info "Releasing: ${BOLD}$current → $new${NC}"
|
||||
hr
|
||||
|
||||
# ── Confirm ───────────────────────────────────────────────────────────────────
|
||||
# ── Confirm ────────────────────────────────────────────────────────────────────
|
||||
|
||||
read -rp "Proceed with release v${new}? [y/N] " confirm
|
||||
[[ "$confirm" =~ ^[Yy]$ ]] || die "Aborted"
|
||||
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
|
||||
|
||||
# ── 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
|
||||
@@ -64,13 +75,12 @@ 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
|
||||
@@ -78,16 +88,14 @@ 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, sys
|
||||
import json, os, re
|
||||
|
||||
version = os.environ['NEW_VERSION']
|
||||
|
||||
@@ -98,7 +106,6 @@ 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]+/',
|
||||
@@ -114,9 +121,9 @@ with open('server.json', 'w') as f:
|
||||
|
||||
print(f" server.json version={version}, identifier URL updated")
|
||||
PYEOF
|
||||
ok "server.json → $new (SHA256 updated by CI)"
|
||||
ok "server.json → $new (SHA256 set 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 "")
|
||||
@@ -138,7 +145,6 @@ 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"
|
||||
@@ -146,22 +152,18 @@ 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 compiles ──────────────────────────────────────────────────
|
||||
# ── 4. Verify build ────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
@@ -172,20 +174,56 @@ 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 ───────────────────────────────────────────────────────────────────
|
||||
# ── 7. Push (rebase if remote has new commits from CI) ─────────────────────────
|
||||
|
||||
info "Pushing to GitHub..."
|
||||
git push origin "$current_branch"
|
||||
git push origin "v${new}"
|
||||
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}"
|
||||
}
|
||||
|
||||
ok "Pushed — GitHub Actions triggered"
|
||||
|
||||
# ── Done ──────────────────────────────────────────────────────────────────────
|
||||
# ── Done ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
hr
|
||||
echo ""
|
||||
|
||||
+2
-2
@@ -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)
|
||||
# - Antigravity : mcp_config.json via UI (JSON, mcpServers)
|
||||
# - Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json
|
||||
#
|
||||
# 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 " - Antigravity:Use Agent panel → MCP Servers → Manage → Edit configuration"
|
||||
echo " - Claude Desktop: Edit ~/Library/Application Support/Claude/claude_desktop_config.json"
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
|
||||
+4
-4
@@ -7,13 +7,13 @@
|
||||
"url": "https://github.com/masdevid/mt5-quant",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "1.31.2",
|
||||
"version": "1.32.1",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "mcpb",
|
||||
"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",
|
||||
"version": "1.32.1",
|
||||
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.32.1/mcp-mt5-quant-macos-arm64.tar.gz",
|
||||
"fileSha256": "a2525e1c0e74587d87f37414652b29b402983625663eb2b3435496e31a2e9714",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
|
||||
@@ -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,22 +24,19 @@ 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;
|
||||
@@ -215,13 +212,6 @@ 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")?;
|
||||
@@ -279,10 +269,6 @@ 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
@@ -12,10 +12,14 @@ 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")]
|
||||
@@ -27,6 +31,18 @@ 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)]
|
||||
@@ -88,6 +104,11 @@ 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 {
|
||||
@@ -97,10 +118,82 @@ 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();
|
||||
|
||||
|
||||
+62
-11
@@ -1,9 +1,12 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use crate::{models::Config as ModelsConfig, tools::ToolHandler, McpError, McpRequest, McpResponse};
|
||||
|
||||
#[allow(dead_code)]
|
||||
type NotificationCallback = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
|
||||
|
||||
/// Auto-verify result stored after first initialization
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
@@ -13,11 +16,17 @@ struct AutoVerifyResult {
|
||||
config_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Notification {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
pub struct McpServer {
|
||||
initialized: Arc<Mutex<bool>>,
|
||||
tool_handler: Arc<ToolHandler>,
|
||||
tool_handler: Arc<Mutex<Option<ToolHandler>>>,
|
||||
auto_verify_result: Arc<Mutex<Option<AutoVerifyResult>>>,
|
||||
notification_tx: Arc<Mutex<Option<mpsc::UnboundedSender<Notification>>>>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
@@ -25,11 +34,38 @@ impl McpServer {
|
||||
let config = ModelsConfig::load().unwrap_or_default();
|
||||
Self {
|
||||
initialized: Arc::new(Mutex::new(false)),
|
||||
tool_handler: Arc::new(ToolHandler::new(config)),
|
||||
tool_handler: Arc::new(Mutex::new(Some(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);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
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();
|
||||
@@ -247,12 +283,27 @@ impl McpServer {
|
||||
}
|
||||
|
||||
async fn handle_tool_call(&self, tool_name: &str, arguments: &Value) -> Value {
|
||||
self.tool_handler.handle(tool_name, arguments).await.unwrap_or_else(|e| json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Tool execution failed: {}", e)
|
||||
}],
|
||||
"isError": true
|
||||
}))
|
||||
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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ 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>,
|
||||
}
|
||||
|
||||
@@ -123,6 +124,7 @@ impl Default for Config {
|
||||
reports_dir: None,
|
||||
backtest_login: None,
|
||||
backtest_server: None,
|
||||
backtest_password: None,
|
||||
project_dir: None,
|
||||
}
|
||||
}
|
||||
@@ -404,6 +406,7 @@ 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(),
|
||||
})
|
||||
}
|
||||
@@ -430,6 +433,7 @@ 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(),
|
||||
}
|
||||
|
||||
@@ -14,8 +14,6 @@ pub struct Report {
|
||||
pub from_date: String,
|
||||
pub to_date: String,
|
||||
pub metrics_file: PathBuf,
|
||||
pub deals_csv: PathBuf,
|
||||
pub deals_json: PathBuf,
|
||||
pub analysis_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -42,8 +40,6 @@ pub struct PipelineMetadata {
|
||||
pub struct FilePaths {
|
||||
pub metrics: String,
|
||||
pub analysis: String,
|
||||
pub deals_csv: String,
|
||||
pub deals_json: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
|
||||
+366
-81
@@ -1,8 +1,10 @@
|
||||
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};
|
||||
@@ -11,11 +13,14 @@ 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 {
|
||||
@@ -38,6 +43,7 @@ pub struct BacktestParams {
|
||||
pub kill_existing: bool,
|
||||
pub timeout: u64,
|
||||
pub gui: bool,
|
||||
pub startup_delay_secs: u64,
|
||||
}
|
||||
|
||||
pub struct PipelineResult {
|
||||
@@ -58,6 +64,21 @@ 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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +138,8 @@ impl BacktestPipeline {
|
||||
let duration = (chrono::Utc::now() - start_time).num_seconds();
|
||||
self.save_metadata(¶ms, &report_dir, duration, extraction.deals.is_empty()).await?;
|
||||
|
||||
// Register in the SQLite report registry.
|
||||
self.register_in_db(
|
||||
// Register in the SQLite report registry and store deals.
|
||||
let db = self.register_in_db(
|
||||
&report_id,
|
||||
¶ms,
|
||||
&report_dir,
|
||||
@@ -129,6 +150,12 @@ 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 {
|
||||
@@ -181,7 +208,7 @@ impl BacktestPipeline {
|
||||
let reports_dir = mt5_dir.join("reports");
|
||||
fs::create_dir_all(&reports_dir)?;
|
||||
|
||||
// Write config files
|
||||
// Write params via /config: (triggers tester) and terminal.ini (redundancy).
|
||||
let ini_content = self.build_backtest_ini(¶ms, &report_id)?;
|
||||
let config_host = wine_prefix.join("drive_c").join("backtest_config.ini");
|
||||
fs::write(&config_host, ini_content.as_bytes())?;
|
||||
@@ -225,9 +252,234 @@ 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();
|
||||
let config_clone = self.config.clone();
|
||||
let params_clone = BacktestParams {
|
||||
expert: params.expert.clone(),
|
||||
symbol: params.symbol.clone(),
|
||||
from_date: params.from_date.clone(),
|
||||
to_date: params.to_date.clone(),
|
||||
timeframe: params.timeframe.clone(),
|
||||
deposit: params.deposit,
|
||||
model: params.model,
|
||||
leverage: params.leverage,
|
||||
set_file: params.set_file.clone(),
|
||||
skip_compile: params.skip_compile,
|
||||
skip_clean: params.skip_clean,
|
||||
skip_analyze: params.skip_analyze,
|
||||
deep_analyze: params.deep_analyze,
|
||||
shutdown: params.shutdown,
|
||||
kill_existing: params.kill_existing,
|
||||
timeout: params.timeout,
|
||||
gui: params.gui,
|
||||
startup_delay_secs: params.startup_delay_secs,
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
Self::monitor_backtest_completion(
|
||||
report_dir_clone,
|
||||
expected_report_clone,
|
||||
timeout_secs,
|
||||
report_id_clone,
|
||||
notification_callback,
|
||||
config_clone,
|
||||
params_clone,
|
||||
).await;
|
||||
});
|
||||
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// Extract deals from a completed report and store them in the DB.
|
||||
/// Returns true if extraction and DB registration succeeded.
|
||||
async fn extract_and_store(
|
||||
report_path: &Path,
|
||||
report_dir: &Path,
|
||||
report_id: &str,
|
||||
config: &Config,
|
||||
params: &BacktestParams,
|
||||
) -> bool {
|
||||
let extractor = ReportExtractor::new();
|
||||
let start_time = chrono::Utc::now();
|
||||
match extractor.extract(
|
||||
&report_path.to_string_lossy(),
|
||||
&report_dir.to_string_lossy(),
|
||||
) {
|
||||
Ok(extraction) => {
|
||||
let duration = (chrono::Utc::now() - start_time).num_seconds();
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
if db.init().is_err() {
|
||||
tracing::warn!("launch_backtest: failed to init DB for {}", report_id);
|
||||
return false;
|
||||
}
|
||||
let entry = ReportEntry {
|
||||
id: report_id.to_string(),
|
||||
expert: params.expert.clone(),
|
||||
symbol: params.symbol.clone(),
|
||||
timeframe: params.timeframe.clone(),
|
||||
model: params.model as i64,
|
||||
from_date: params.from_date.clone(),
|
||||
to_date: params.to_date.clone(),
|
||||
created_at: chrono::Utc::now().to_rfc3339(),
|
||||
set_file_original: params.set_file.clone(),
|
||||
set_snapshot_path: None,
|
||||
report_dir: report_dir.to_string_lossy().to_string(),
|
||||
charts_dir: None,
|
||||
net_profit: Some(extraction.metrics.net_profit),
|
||||
profit_factor: Some(extraction.metrics.profit_factor),
|
||||
max_dd_pct: Some(extraction.metrics.max_dd_pct),
|
||||
sharpe_ratio: Some(extraction.metrics.sharpe_ratio),
|
||||
total_trades: Some(extraction.metrics.total_trades as i64),
|
||||
win_rate_pct: Some(extraction.metrics.win_rate_pct),
|
||||
recovery_factor: Some(extraction.metrics.recovery_factor),
|
||||
deposit: Some(params.deposit as f64),
|
||||
currency: config.backtest_currency.clone(),
|
||||
leverage: Some(params.leverage as i64),
|
||||
duration_seconds: Some(duration),
|
||||
tags: Vec::new(),
|
||||
notes: None,
|
||||
verdict: None,
|
||||
};
|
||||
if let Err(e) = db.insert(&entry) {
|
||||
tracing::warn!("launch_backtest: failed to register report in DB: {}", e);
|
||||
return false;
|
||||
}
|
||||
if let Err(e) = db.insert_deals(report_id, &extraction.deals) {
|
||||
tracing::warn!("launch_backtest: failed to store deals in DB: {}", e);
|
||||
}
|
||||
tracing::info!("launch_backtest: extracted {} deals for {}", extraction.deals.len(), report_id);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("launch_backtest: extraction failed for {}: {}", report_id, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background task to monitor backtest completion and update status file.
|
||||
async fn monitor_backtest_completion(
|
||||
report_dir: PathBuf,
|
||||
expected_report: PathBuf,
|
||||
timeout_secs: u64,
|
||||
report_id: String,
|
||||
notification_callback: Option<NotificationCallback>,
|
||||
config: Config,
|
||||
params: BacktestParams,
|
||||
) {
|
||||
let start = tokio::time::Instant::now();
|
||||
let deadline = start + Duration::from_secs(timeout_secs);
|
||||
let grace_period = Duration::from_secs(30);
|
||||
let poll_start = std::time::SystemTime::now();
|
||||
|
||||
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());
|
||||
let extracted = Self::extract_and_store(&candidate, &report_dir, &report_id, &config, ¶ms).await;
|
||||
if extracted {
|
||||
let _ = fs::remove_file(&candidate);
|
||||
} else {
|
||||
tracing::warn!("Backtest {}: extraction failed, keeping report file at {}", report_id, candidate.display());
|
||||
}
|
||||
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
|
||||
let reports_parent = match expected_report.parent() {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
tracing::error!("Backtest {}: expected_report path has no parent", report_id);
|
||||
Self::update_job_status(&report_dir, "failed", None).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(path) = Self::find_newest_report(reports_parent, poll_start) {
|
||||
tracing::info!("Backtest {} completed: found fallback report {}", report_id, path.display());
|
||||
let extracted = Self::extract_and_store(&path, &report_dir, &report_id, &config, ¶ms).await;
|
||||
if extracted {
|
||||
let _ = fs::remove_file(&path);
|
||||
} else {
|
||||
tracing::warn!("Backtest {}: extraction failed, keeping fallback report at {}", report_id, path.display());
|
||||
}
|
||||
Self::update_job_status(&report_dir, "completed", Some(path.to_string_lossy().to_string())).await;
|
||||
if let Some(ref callback) = notification_callback {
|
||||
callback("backtest_completed", json!({
|
||||
"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> {
|
||||
@@ -289,11 +541,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;
|
||||
return None;
|
||||
}
|
||||
|
||||
let entry = ReportEntry {
|
||||
@@ -327,7 +579,10 @@ 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<()> {
|
||||
@@ -451,12 +706,8 @@ impl BacktestPipeline {
|
||||
let reports_dir = mt5_dir.join("reports");
|
||||
fs::create_dir_all(&reports_dir)?;
|
||||
|
||||
// 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.
|
||||
// Write params via /config: (triggers tester auto-start) and terminal.ini (redundancy).
|
||||
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)?;
|
||||
@@ -477,8 +728,10 @@ impl BacktestPipeline {
|
||||
.spawn()?;
|
||||
|
||||
// Give MT5 time to fully initialize before polling.
|
||||
// MT5 app startup (Wine init + network auth + tester) typically takes 15–20 s.
|
||||
sleep(Duration::from_secs(20)).await;
|
||||
// MT5 app startup (Wine init + network auth + tester) typically takes 10–15 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;
|
||||
|
||||
// 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 —
|
||||
@@ -523,15 +776,21 @@ impl BacktestPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// Write backtest parameters into terminal.ini [Tester] section so MT5 reads
|
||||
/// them on startup without needing a /config: command-line argument.
|
||||
/// 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.
|
||||
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"))?;
|
||||
let terminal_ini = mt5_dir.join("config").join("terminal.ini");
|
||||
// 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 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]]))
|
||||
@@ -544,22 +803,32 @@ 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(¶ms.from_date)?;
|
||||
let to_ts = Self::date_str_to_unix(¶ms.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(¶ms.expert).join(format!("{}.mq5", params.expert));
|
||||
if nested.exists() {
|
||||
format!("Experts\\{}\\{}.ex5", params.expert, params.expert)
|
||||
} else {
|
||||
format!("Experts\\{}.ex5", params.expert)
|
||||
}
|
||||
} else {
|
||||
format!("Experts\\{}.ex5", params.expert)
|
||||
};
|
||||
|
||||
let set_file_line = params.set_file.as_ref()
|
||||
.map(|p| format!("ExpertParameters={}\n", p))
|
||||
.map(|p| format!("ExpertParameters={}\n", Self::ini_safe(p)))
|
||||
.unwrap_or_default();
|
||||
|
||||
let updates: &[(&str, String)] = &[
|
||||
("Expert", self.resolve_expert_path(¶ms.expert)),
|
||||
("Symbol", params.symbol.clone()),
|
||||
("Expert", Self::ini_safe(&expert_path)),
|
||||
("Symbol", Self::ini_safe(¶ms.symbol)),
|
||||
("Period", period.to_string()),
|
||||
("DateRange", "3".into()),
|
||||
("DateFrom", from_ts.to_string()),
|
||||
@@ -577,31 +846,20 @@ 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 for backtest {}", report_id);
|
||||
tracing::info!("terminal.ini [Tester] updated → {}", terminal_ini.display());
|
||||
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())
|
||||
/// Strip CR/LF from a user-supplied INI value to prevent newline injection.
|
||||
fn ini_safe(value: &str) -> String {
|
||||
value.replace(['\n', '\r'], "")
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -618,7 +876,6 @@ 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));
|
||||
}
|
||||
@@ -637,7 +894,6 @@ 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));
|
||||
@@ -646,19 +902,33 @@ 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 `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 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 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 with /config: directly.
|
||||
// DYLD_FALLBACK_LIBRARY_PATH and invokes wine64 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/
|
||||
@@ -671,11 +941,13 @@ impl BacktestPipeline {
|
||||
let dyld = format!("{}:{}:/usr/lib:/usr/local/lib",
|
||||
ext_libs.display(), wine_libs.display());
|
||||
|
||||
// Use host path for the exe; config at drive root to avoid spaces in path.
|
||||
// Use host path for the exe; use /config: with backslash-escaped path
|
||||
let terminal_host = wine_prefix.join("drive_c")
|
||||
.join("Program Files").join("MetaTrader 5").join("terminal64.exe");
|
||||
let config_win = r"C:\backtest_config.ini";
|
||||
|
||||
// /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\
|
||||
@@ -691,43 +963,41 @@ impl BacktestPipeline {
|
||||
);
|
||||
|
||||
let script_path = std::env::temp_dir().join("mt5_backtest_launch.sh");
|
||||
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))?;
|
||||
|
||||
// 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());
|
||||
}
|
||||
|
||||
tracing::info!("Launching MT5 via shell script: {}", script_path.display());
|
||||
tracing::info!("Launching MT5 via shell script (terminal.ini mode): {}", script_path.display());
|
||||
let mut cmd = Command::new("/bin/sh");
|
||||
cmd.arg(&script_path);
|
||||
return Ok(cmd);
|
||||
}
|
||||
|
||||
// CrossOver / Linux: invoke wine64 directly with WINEPREFIX set.
|
||||
// Params are already written to terminal.ini so no /config: arg needed.
|
||||
// CrossOver / Linux: invoke wine64 directly with /config: to trigger the tester.
|
||||
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 {
|
||||
@@ -744,19 +1014,25 @@ 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\n", server));
|
||||
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("[Tester]\n");
|
||||
// Expert path is relative to MQL5/Experts/ in the /config: format (no "Experts\" prefix).
|
||||
ini.push_str(&format!("Expert={}\n", self.resolve_backtest_ini_expert_path(¶ms.expert)));
|
||||
ini.push_str(&format!("Symbol={}\n", params.symbol));
|
||||
ini.push_str(&format!("Period={}\n", params.timeframe));
|
||||
ini.push_str(&format!("Expert={}\n", Self::ini_safe(&self.resolve_backtest_ini_expert_path(¶ms.expert))));
|
||||
ini.push_str(&format!("Symbol={}\n", Self::ini_safe(¶ms.symbol)));
|
||||
ini.push_str(&format!("Period={}\n", Self::ini_safe(¶ms.timeframe)));
|
||||
ini.push_str("Optimization=0\n");
|
||||
ini.push_str(&format!("Model={}\n", params.model));
|
||||
ini.push_str(&format!("FromDate={}\n", params.from_date));
|
||||
@@ -773,7 +1049,7 @@ impl BacktestPipeline {
|
||||
ini.push_str(&format!("ShutdownTerminal={}\n", if params.shutdown { "1" } else { "0" }));
|
||||
|
||||
if let Some(set_file) = ¶ms.set_file {
|
||||
ini.push_str(&format!("ExpertParameters={}\n", set_file));
|
||||
ini.push_str(&format!("ExpertParameters={}\n", Self::ini_safe(set_file)));
|
||||
}
|
||||
|
||||
Ok(ini)
|
||||
@@ -796,11 +1072,22 @@ 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 _ = Command::new("pkill").args(["-KILL", "-f", pat.as_str()]).output();
|
||||
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", "wineserver"]).output();
|
||||
sleep(Duration::from_secs(3)).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -877,8 +1164,6 @@ impl BacktestPipeline {
|
||||
files: FilePaths {
|
||||
metrics: report_dir.join("metrics.json").to_string_lossy().to_string(),
|
||||
analysis: report_dir.join("analysis.json").to_string_lossy().to_string(),
|
||||
deals_csv: report_dir.join("deals.csv").to_string_lossy().to_string(),
|
||||
deals_json: report_dir.join("deals.json").to_string_lossy().to_string(),
|
||||
},
|
||||
no_trades,
|
||||
};
|
||||
|
||||
+168
-22
@@ -3,6 +3,8 @@ 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,
|
||||
@@ -67,6 +69,25 @@ 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,
|
||||
@@ -102,6 +123,69 @@ 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)?;
|
||||
@@ -375,6 +459,57 @@ 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()?;
|
||||
@@ -429,26 +564,39 @@ impl ReportDb {
|
||||
/// Search reports by tags (at least one tag must match)
|
||||
pub fn search_by_tags(&self, tags: &[String], limit: usize) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
let base_sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE 1=1"
|
||||
.to_string();
|
||||
FROM reports WHERE 1=1";
|
||||
|
||||
// Build OR conditions for tags - use JSON1 extension for tag matching
|
||||
if !tags.is_empty() {
|
||||
let tag_conditions: Vec<String> = tags.iter()
|
||||
.map(|tag| format!("tags LIKE '%{}%'", tag.replace("'", "''")))
|
||||
let (sql, params): (String, Vec<rusqlite::types::Value>) = if tags.is_empty() {
|
||||
(
|
||||
format!("{} ORDER BY created_at DESC LIMIT ?1", base_sql),
|
||||
vec![(limit as i64).into()],
|
||||
)
|
||||
} else {
|
||||
let placeholders = tags.iter().enumerate()
|
||||
.map(|(i, _)| format!("tags LIKE ?{}", i + 1))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" OR ");
|
||||
let sql = format!(
|
||||
"{} AND ({}) ORDER BY created_at DESC LIMIT ?{}",
|
||||
base_sql,
|
||||
placeholders,
|
||||
tags.len() + 1
|
||||
);
|
||||
let mut p: Vec<rusqlite::types::Value> = tags.iter()
|
||||
.map(|t| format!("%{}%", t).into())
|
||||
.collect();
|
||||
sql.push_str(&format!(" AND ({})", tag_conditions.join(" OR ")));
|
||||
}
|
||||
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
|
||||
p.push((limit as i64).into());
|
||||
(sql, p)
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
@@ -489,19 +637,18 @@ impl ReportDb {
|
||||
/// Search reports by notes text (case-insensitive LIKE)
|
||||
pub fn search_by_notes(&self, query: &str, limit: usize) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let pattern = format!("%{}%", query.replace("'", "''"));
|
||||
let pattern = format!("%{}%", query);
|
||||
let mut stmt = conn.prepare(
|
||||
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
"SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE notes LIKE '{}' ORDER BY created_at DESC LIMIT {}",
|
||||
pattern, limit)
|
||||
FROM reports WHERE notes LIKE ?1 ORDER BY created_at DESC LIMIT ?2"
|
||||
)?;
|
||||
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
.query_map(rusqlite::params![pattern, limit as i64], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
@@ -542,20 +689,19 @@ impl ReportDb {
|
||||
/// Find reports by set file (original or snapshot)
|
||||
pub fn search_by_set_file(&self, set_file: &str, limit: usize) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let pattern = format!("%{}%", set_file.replace("'", "''"));
|
||||
let pattern = format!("%{}%", set_file);
|
||||
let mut stmt = conn.prepare(
|
||||
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
"SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE (set_file_original LIKE '{}' OR set_snapshot_path LIKE '{}') \
|
||||
ORDER BY created_at DESC LIMIT {}",
|
||||
pattern, pattern, limit)
|
||||
FROM reports WHERE (set_file_original LIKE ?1 OR set_snapshot_path LIKE ?1) \
|
||||
ORDER BY created_at DESC LIMIT ?2"
|
||||
)?;
|
||||
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
.query_map(rusqlite::params![pattern, limit as i64], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
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",
|
||||
@@ -7,7 +10,8 @@ pub fn tool_analyze_report() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" },
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory (use report_id instead)" },
|
||||
"analytics": {
|
||||
"type": "array",
|
||||
"description": "Optional: specific analytics to run. If omitted, runs all.",
|
||||
@@ -29,7 +33,8 @@ pub fn tool_analyze_monthly_pnl() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -42,7 +47,8 @@ pub fn tool_analyze_drawdown_events() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -55,7 +61,8 @@ pub fn tool_analyze_top_losses() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" },
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" },
|
||||
"limit": { "type": "integer", "description": "Number of losses to return (default: 10)", "default": 10 }
|
||||
}
|
||||
}
|
||||
@@ -69,7 +76,8 @@ pub fn tool_analyze_loss_sequences() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -82,7 +90,8 @@ pub fn tool_analyze_position_pairs() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -95,7 +104,8 @@ pub fn tool_analyze_direction_bias() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -108,7 +118,8 @@ pub fn tool_analyze_streaks() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -121,7 +132,8 @@ pub fn tool_analyze_concurrent_peak() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -133,9 +145,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_dir": { "type": "string", "description": "Path to report directory containing deals.csv" },
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" },
|
||||
"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" },
|
||||
@@ -155,9 +167,10 @@ pub fn tool_search_deals_by_comment() -> Value {
|
||||
"description": "Search deals by comment text (case-insensitive partial match)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir", "query"],
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" },
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" },
|
||||
"query": { "type": "string", "description": "Search text in comments" },
|
||||
"limit": { "type": "integer", "default": 50 }
|
||||
}
|
||||
@@ -171,9 +184,10 @@ pub fn tool_search_deals_by_magic() -> Value {
|
||||
"description": "Filter deals by magic number (EA identifier)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir", "magic"],
|
||||
"required": ["magic"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" },
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" },
|
||||
"magic": { "type": "string", "description": "Magic number to filter by" },
|
||||
"limit": { "type": "integer", "default": 100 }
|
||||
}
|
||||
@@ -187,9 +201,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_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -201,9 +215,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_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -215,9 +229,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_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -229,9 +243,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_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -243,9 +257,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_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -257,9 +271,9 @@ pub fn tool_analyze_costs() -> Value {
|
||||
"description": "Analyze commission and swap costs impact on profitability",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -271,9 +285,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_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -23,7 +23,8 @@ 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" }
|
||||
"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)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -48,7 +49,8 @@ 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" }
|
||||
"gui": { "type": "boolean", "description": "Enable MT5 visualization" },
|
||||
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -97,7 +99,8 @@ 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" }
|
||||
"gui": { "type": "boolean", "description": "Enable visualization during backtest" },
|
||||
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -63,6 +63,8 @@ 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(),
|
||||
@@ -110,6 +112,7 @@ 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)
|
||||
|
||||
@@ -278,3 +278,17 @@ 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)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,3 +45,25 @@ 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": {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -289,7 +289,7 @@ pub fn tool_get_wine_prefix_info() -> Value {
|
||||
pub fn tool_get_backtest_crash_info() -> Value {
|
||||
json!({
|
||||
"name": "get_backtest_crash_info",
|
||||
"description": "Investigate backtest crashes/failures. Checks for incomplete markers, missing deals.csv, error logs. Can scan recent reports.",
|
||||
"description": "Investigate backtest crashes/failures. Checks for incomplete markers, missing metrics.json, DB deal count, error logs. Can scan recent reports.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::analytics::DealAnalyzer;
|
||||
use crate::models::deals::Deal;
|
||||
use crate::models::metrics::Metrics;
|
||||
use crate::models::Config;
|
||||
use crate::storage::ReportDb;
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -30,66 +31,46 @@ fn err_response(msg: impl std::fmt::Display) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
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");
|
||||
/// 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()?;
|
||||
|
||||
if !deals_csv.exists() {
|
||||
return Err(anyhow::anyhow!("deals.csv not found in {}", report_dir));
|
||||
}
|
||||
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."))?
|
||||
};
|
||||
|
||||
let deals = read_deals_from_csv(&deals_csv)?;
|
||||
let metrics = if metrics_json.exists() {
|
||||
serde_json::from_str(&fs::read_to_string(&metrics_json)?)?
|
||||
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)?)?
|
||||
} else {
|
||||
Metrics::default()
|
||||
};
|
||||
|
||||
Ok((deals, metrics))
|
||||
Ok((deals, metrics, entry.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)
|
||||
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))
|
||||
}
|
||||
|
||||
// ── Composite analytics ───────────────────────────────────────────────────────
|
||||
|
||||
pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, metrics, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, metrics, analyzer, report_dir) = prepare_analysis(args)?;
|
||||
|
||||
let requested: Option<HashSet<String>> = args.get("analytics")
|
||||
.and_then(|v| v.as_array())
|
||||
@@ -111,7 +92,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!({
|
||||
@@ -123,10 +104,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 = required_str(args, "report_dir")?;
|
||||
let (_, _, _, report_dir) = prepare_analysis(args)?;
|
||||
|
||||
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/")));
|
||||
@@ -150,93 +131,78 @@ 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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, metrics, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, metrics, analyzer, _) = prepare_analysis(args)?;
|
||||
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(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, metrics, analyzer) = prepare_analysis(report_dir)?;
|
||||
let (deals, metrics, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "efficiency_analysis": analyzer.efficiency_analysis(&deals, &metrics) })))
|
||||
}
|
||||
|
||||
@@ -263,8 +229,7 @@ fn is_closed_trade(d: &Deal) -> bool {
|
||||
}
|
||||
|
||||
pub async fn handle_list_deals(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let (deals, _, _, _) = prepare_analysis(args)?;
|
||||
|
||||
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());
|
||||
@@ -299,11 +264,9 @@ 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, _) = load_report_data(report_dir)?;
|
||||
let (deals, _, _, _) = prepare_analysis(args)?;
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
let mut filtered: Vec<&Deal> = deals.iter()
|
||||
@@ -322,11 +285,9 @@ 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, _) = load_report_data(report_dir)?;
|
||||
let (deals, _, _, _) = prepare_analysis(args)?;
|
||||
|
||||
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))
|
||||
@@ -343,6 +304,5 @@ 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 _use_err_response() { let _ = err_response(""); }
|
||||
fn _err_response_available() { let _ = err_response(""); }
|
||||
|
||||
@@ -147,12 +147,12 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value>
|
||||
}));
|
||||
}
|
||||
|
||||
// Date defaulting: past complete calendar month
|
||||
// Date defaulting: current 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::past_complete_month()
|
||||
super::current_month()
|
||||
} else {
|
||||
(f.to_string(), t.to_string())
|
||||
}
|
||||
@@ -176,6 +176,7 @@ 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());
|
||||
@@ -212,13 +213,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(config: &Config, args: &Value) -> Result<Value> {
|
||||
pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandler, 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(config, expert);
|
||||
let preflight = BacktestPreflight::check(&handler.config, expert);
|
||||
|
||||
// Check account session
|
||||
if preflight.account.is_none() {
|
||||
@@ -237,7 +238,7 @@ pub async fn handle_launch_backtest(config: &Config, args: &Value) -> Result<Val
|
||||
.unwrap_or("");
|
||||
|
||||
let symbol = if requested_symbol.is_empty() {
|
||||
config.backtest_symbol.clone()
|
||||
handler.config.backtest_symbol.clone()
|
||||
.or_else(|| preflight.available_symbols.first().cloned())
|
||||
.unwrap_or_else(|| "EURUSD".to_string())
|
||||
} else {
|
||||
@@ -260,7 +261,7 @@ pub async fn handle_launch_backtest(config: &Config, args: &Value) -> Result<Val
|
||||
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::past_complete_month()
|
||||
super::current_month()
|
||||
} else {
|
||||
(f.to_string(), t.to_string())
|
||||
}
|
||||
@@ -284,9 +285,14 @@ pub async fn handle_launch_backtest(config: &Config, args: &Value) -> Result<Val
|
||||
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 = BacktestPipeline::new(config.clone());
|
||||
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 job = pipeline.launch_backtest(params).await?;
|
||||
|
||||
Ok(json!({
|
||||
@@ -349,7 +355,6 @@ pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Resul
|
||||
|
||||
// Check for completed artifacts
|
||||
let metrics_exists = report_path.join("metrics.json").exists();
|
||||
let deals_exists = report_path.join("deals.csv").exists();
|
||||
let is_complete = stage == "DONE" || (report_found && metrics_exists);
|
||||
|
||||
// Calculate elapsed time if job exists
|
||||
@@ -394,7 +399,6 @@ pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Resul
|
||||
"mt5_running": mt5_running,
|
||||
"report_found": report_found,
|
||||
"metrics_extracted": metrics_exists,
|
||||
"deals_extracted": deals_exists,
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
"message": message,
|
||||
"job": job.map(|j| {
|
||||
|
||||
@@ -2,8 +2,12 @@ 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;
|
||||
@@ -13,23 +17,45 @@ mod setfiles;
|
||||
mod reports;
|
||||
mod utility;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// 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)]
|
||||
pub struct ToolHandler {
|
||||
pub config: Config,
|
||||
pub notification_callback: Option<NotificationCallback>,
|
||||
}
|
||||
|
||||
impl ToolHandler {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self { config }
|
||||
Self { config, notification_callback: None }
|
||||
}
|
||||
|
||||
pub fn with_notification_callback(config: Config, callback: NotificationCallback) -> Self {
|
||||
Self { config, notification_callback: Some(callback) }
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -46,7 +72,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.config, args).await, // Fire-and-forget mode
|
||||
"launch_backtest" => backtest::handle_launch_backtest(self, args).await, // Fire-and-forget mode
|
||||
"get_backtest_status" => backtest::handle_get_backtest_status(&self.config, args).await,
|
||||
"cache_status" => backtest::handle_cache_status(&self.config).await,
|
||||
"clean_cache" => backtest::handle_clean_cache(&self.config, args).await,
|
||||
@@ -109,6 +135,7 @@ 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,
|
||||
@@ -152,6 +179,7 @@ pub(crate) fn dir_size(path: &Path) -> u64 {
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn past_complete_month() -> (String, String) {
|
||||
let now = chrono::Utc::now();
|
||||
let today = chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
|
||||
@@ -165,3 +193,24 @@ 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(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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};
|
||||
|
||||
@@ -848,3 +849,45 @@ 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
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,178 @@ 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, ¤t_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;
|
||||
|
||||
@@ -3,6 +3,7 @@ use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use crate::models::Config;
|
||||
use crate::storage::ReportDb;
|
||||
|
||||
/// Validate that `user_path` resolves to a location within `allowed_base`.
|
||||
/// Returns the canonicalized absolute path on success.
|
||||
@@ -785,7 +786,6 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result<Valu
|
||||
|
||||
let path = Path::new(report_dir);
|
||||
let metrics_path = path.join("metrics.json");
|
||||
let _deals_path = path.join("deals.csv");
|
||||
|
||||
// Read metrics
|
||||
let metrics: Value = if metrics_path.exists() {
|
||||
@@ -1640,21 +1640,33 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re
|
||||
}
|
||||
}
|
||||
|
||||
// Check if deals.csv is missing or empty
|
||||
let deals_csv = path.join("deals.csv");
|
||||
if !deals_csv.exists() {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "missing_deals",
|
||||
"reason": "deals.csv not found - backtest likely failed",
|
||||
}));
|
||||
} else if let Ok(meta) = deals_csv.metadata() {
|
||||
if meta.len() < 100 {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "empty_deals",
|
||||
"reason": "deals.csv is nearly empty - no trades were made",
|
||||
}));
|
||||
// Check deal count in DB
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
if db.init().is_ok() {
|
||||
match db.get_by_report_dir(dir) {
|
||||
Ok(Some(entry)) => {
|
||||
let deal_count = db.get_deals(&entry.id)
|
||||
.map(|d| d.len())
|
||||
.unwrap_or(0);
|
||||
if deal_count == 0 {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "empty_deals",
|
||||
"reason": "No deals stored in DB - EA did not trade or extraction failed",
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(None) | Err(_) => {
|
||||
// Not in DB at all means extraction never completed
|
||||
let metrics_exists = path.join("metrics.json").exists();
|
||||
if !metrics_exists {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "missing_deals",
|
||||
"reason": "Report not found in DB and no metrics.json - backtest likely crashed before extraction",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1676,10 +1688,10 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if modified >= cutoff {
|
||||
// Check for failure indicators
|
||||
let has_deals = path.join("deals.csv").exists();
|
||||
let has_metrics = path.join("metrics.json").exists();
|
||||
let has_incomplete = path.join(".incomplete").exists();
|
||||
|
||||
if !has_deals || has_incomplete {
|
||||
|
||||
if !has_metrics || has_incomplete {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
@@ -1701,7 +1713,7 @@ pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Re
|
||||
|
||||
if types.contains(&"missing_deals".to_string()) {
|
||||
result["common_patterns"].as_array_mut().unwrap().push(
|
||||
json!("Missing deals.csv suggests MT5 crashed during backtest")
|
||||
json!("Report not in DB and no metrics.json suggests MT5 crashed before extraction completed")
|
||||
);
|
||||
}
|
||||
if types.contains(&"incomplete".to_string()) {
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user