Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 262936a1d5 | |||
| 0ea1e779e0 | |||
| a94d75823a | |||
| c7e1a03e1f | |||
| 23c67e80c5 | |||
| 5db3c7c822 | |||
| 5eb753584d | |||
| 7c9eeef1c4 | |||
| 20a2d3d6e4 | |||
| ec2a22ed30 | |||
| 94a8c42e68 | |||
| 7e97086909 | |||
| 7a8a8daeca | |||
| ba04405454 | |||
| e40dbf63cf | |||
| 0a2e076ad6 | |||
| 8f71f1629d | |||
| ad17f9dba6 | |||
| 33186d276a | |||
| 3927343999 | |||
| 6a5253186b | |||
| 76dac96583 | |||
| 0bc410f613 | |||
| 9be1296916 | |||
| 63c0470c6b |
@@ -0,0 +1,399 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version tag (e.g., v1.32.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
# ── Build binaries ───────────────────────────────────────────────────────────
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: macos-cargo-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: macos-cargo-
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release
|
||||
|
||||
- name: Package binary
|
||||
run: |
|
||||
mkdir -p dist/mcp-mt5-quant-macos
|
||||
cp target/release/mt5-quant dist/mcp-mt5-quant-macos/
|
||||
cp -r config dist/mcp-mt5-quant-macos/
|
||||
cp README.md dist/mcp-mt5-quant-macos/
|
||||
cp -r docs dist/mcp-mt5-quant-macos/
|
||||
cd dist
|
||||
tar -czf mcp-mt5-quant-macos-arm64.tar.gz mcp-mt5-quant-macos
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-binary
|
||||
path: dist/mcp-mt5-quant-macos-arm64.tar.gz
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: linux-cargo-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: linux-cargo-
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release
|
||||
|
||||
- name: Package binary
|
||||
run: |
|
||||
mkdir -p dist/mcp-mt5-quant-linux
|
||||
cp target/release/mt5-quant dist/mcp-mt5-quant-linux/
|
||||
cp -r config dist/mcp-mt5-quant-linux/
|
||||
cp README.md dist/mcp-mt5-quant-linux/
|
||||
cp -r docs dist/mcp-mt5-quant-linux/
|
||||
cd dist
|
||||
tar -czf mcp-mt5-quant-linux-x64.tar.gz mcp-mt5-quant-linux
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-binary
|
||||
path: dist/mcp-mt5-quant-linux-x64.tar.gz
|
||||
|
||||
# ── GitHub Release ───────────────────────────────────────────────────────────
|
||||
|
||||
release:
|
||||
needs: [build-macos, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download macOS artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-binary
|
||||
path: dist
|
||||
|
||||
- name: Download Linux artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: linux-binary
|
||||
path: dist
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ env.RELEASE_TAG }}
|
||||
name: ${{ env.RELEASE_TAG }}
|
||||
files: |
|
||||
dist/mcp-mt5-quant-macos-arm64.tar.gz
|
||||
dist/mcp-mt5-quant-linux-x64.tar.gz
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# ── MCP Package ──────────────────────────────────────────────────────────────
|
||||
# Builds the installable MCP package (binary + config + docs),
|
||||
# computes its SHA256, and uploads to the release.
|
||||
|
||||
build-mcp-package:
|
||||
needs: release
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
sha256: ${{ steps.compute-sha256.outputs.sha256 }}
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download macOS binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: macos-binary
|
||||
path: dist
|
||||
|
||||
- name: Build MCP package
|
||||
run: |
|
||||
mkdir -p mcp-package/mcp-server/bin
|
||||
cd dist && tar -xzf mcp-mt5-quant-macos-arm64.tar.gz && cd ..
|
||||
cp dist/mcp-mt5-quant-macos/mt5-quant mcp-package/mcp-server/bin/
|
||||
cp -r config mcp-package/
|
||||
cp -r docs mcp-package/
|
||||
cp README.md mcp-package/
|
||||
cp server.json mcp-package/
|
||||
cd mcp-package && tar -czf ../mcp-mt5-quant-macos-arm64.tar.gz . && cd ..
|
||||
|
||||
- name: Compute SHA256
|
||||
id: compute-sha256
|
||||
run: |
|
||||
SHA256=$(shasum -a 256 mcp-mt5-quant-macos-arm64.tar.gz | awk '{print $1}')
|
||||
echo "sha256=$SHA256" >> "$GITHUB_OUTPUT"
|
||||
echo "MCP Package SHA256: $SHA256"
|
||||
|
||||
- name: Upload MCP package to release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ env.RELEASE_TAG }}
|
||||
files: mcp-mt5-quant-macos-arm64.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload MCP package artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mcp-package
|
||||
path: mcp-mt5-quant-macos-arm64.tar.gz
|
||||
|
||||
# ── Update server.json SHA256 ────────────────────────────────────────────────
|
||||
# Commits the computed SHA256 back to master so server.json always
|
||||
# reflects the latest published package hash.
|
||||
|
||||
update-server-json:
|
||||
needs: build-mcp-package
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: master
|
||||
fetch-depth: 1
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Patch server.json
|
||||
env:
|
||||
PKG_SHA256: ${{ needs.build-mcp-package.outputs.sha256 }}
|
||||
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
|
||||
run: |
|
||||
VERSION="${RELEASE_TAG#v}"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
VERSION="$VERSION" PKG_SHA256="$PKG_SHA256" python3 - <<'PYEOF'
|
||||
import json, os, re
|
||||
|
||||
sha256 = os.environ['PKG_SHA256']
|
||||
version = os.environ['VERSION']
|
||||
|
||||
with open('server.json') as f:
|
||||
data = json.load(f)
|
||||
|
||||
data['version'] = version
|
||||
for pkg in data.get('packages', []):
|
||||
pkg['version'] = version
|
||||
pkg['fileSha256'] = sha256
|
||||
if 'identifier' in pkg:
|
||||
pkg['identifier'] = re.sub(
|
||||
r'/v[0-9]+\.[0-9]+\.[0-9]+/',
|
||||
f'/v{version}/',
|
||||
pkg['identifier']
|
||||
)
|
||||
|
||||
with open('server.json', 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write('\n')
|
||||
|
||||
print(f"Patched: version={version}, sha256={sha256[:16]}...")
|
||||
PYEOF
|
||||
|
||||
- name: Commit server.json
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add server.json
|
||||
if git diff --cached --quiet; then
|
||||
echo "server.json unchanged — no commit needed"
|
||||
else
|
||||
git commit -m "ci: update server.json SHA256 for v${VERSION} [skip ci]"
|
||||
git push origin master
|
||||
echo "Committed server.json update"
|
||||
fi
|
||||
|
||||
# ── MCP Registry Publish ─────────────────────────────────────────────────────
|
||||
|
||||
mcp-publish:
|
||||
needs: [build-mcp-package, update-server-json]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for GitHub OIDC
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: master # Pull master so server.json has the correct SHA256
|
||||
|
||||
- name: Download MCP package
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: mcp-package
|
||||
path: .
|
||||
|
||||
- name: Install mcp-publisher
|
||||
id: install-publisher
|
||||
run: |
|
||||
# Fetch the download URL for linux_amd64 directly from the release assets API
|
||||
# (asset name format changed in v1.6.0 — look it up rather than constructing it)
|
||||
URL=$(curl -sf \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"https://api.github.com/repos/modelcontextprotocol/registry/releases/latest" \
|
||||
| python3 -c "
|
||||
import sys, json
|
||||
r = json.load(sys.stdin)
|
||||
tag = r['tag_name']
|
||||
assets = r.get('assets', [])
|
||||
match = next((a['browser_download_url'] for a in assets
|
||||
if 'linux' in a['name'] and 'amd64' in a['name']
|
||||
and a['name'].endswith('.tar.gz')
|
||||
and 'sbom' not in a['name']
|
||||
and 'sigstore' not in a['name']), None)
|
||||
if match:
|
||||
print(match)
|
||||
else:
|
||||
print(f'https://github.com/modelcontextprotocol/registry/releases/download/{tag}/mcp-publisher_linux_amd64.tar.gz', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
" 2>/dev/null)
|
||||
|
||||
if [[ -z "$URL" ]]; then
|
||||
echo "installed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Could not resolve mcp-publisher download URL"
|
||||
else
|
||||
echo "Downloading: $URL"
|
||||
if curl -fsSL -o mcp-publisher.tar.gz "$URL"; then
|
||||
tar -xzf mcp-publisher.tar.gz 2>/dev/null || true
|
||||
BINARY=$(find . -maxdepth 3 -name "mcp-publisher" ! -path "./.git/*" | head -1)
|
||||
if [[ -n "$BINARY" && -f "$BINARY" ]]; then
|
||||
chmod +x "$BINARY"
|
||||
sudo mv "$BINARY" /usr/local/bin/mcp-publisher
|
||||
echo "installed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Installed: $(mcp-publisher --version 2>/dev/null || echo 'ok')"
|
||||
else
|
||||
echo "installed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Binary not found in archive"
|
||||
fi
|
||||
rm -f mcp-publisher.tar.gz
|
||||
else
|
||||
echo "installed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Download failed: $URL"
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Validate server.json before publish
|
||||
id: validate
|
||||
run: |
|
||||
python3 - <<'PYEOF'
|
||||
import json, sys
|
||||
|
||||
with open('server.json') as f:
|
||||
data = json.load(f)
|
||||
|
||||
print(f" name: {data.get('name')}")
|
||||
print(f" version: {data.get('version')}")
|
||||
|
||||
ok = True
|
||||
for pkg in data.get('packages', []):
|
||||
sha = pkg.get('fileSha256', '')
|
||||
ident = pkg.get('identifier', '')
|
||||
print(f" sha256: {sha}")
|
||||
print(f" url: {ident}")
|
||||
if sha in ('', 'TBD_CI_WILL_UPDATE', 'TBD_UPDATED_BY_CI'):
|
||||
print('ERROR: fileSha256 is a placeholder — publish aborted')
|
||||
ok = False
|
||||
|
||||
sys.exit(0 if ok else 1)
|
||||
PYEOF
|
||||
|
||||
- name: Publish to MCP Registry
|
||||
if: steps.install-publisher.outputs.installed == 'true' && steps.validate.outcome == 'success'
|
||||
run: |
|
||||
echo "=== Authenticating with GitHub OIDC ==="
|
||||
mcp-publisher login github-oidc || {
|
||||
echo "OIDC login failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "=== Publishing ==="
|
||||
mcp-publisher publish && echo "✓ Published to MCP Registry" || {
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
echo " AUTOMATED MCP PUBLISH FAILED — manual steps:"
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
echo " 1. Download: https://github.com/modelcontextprotocol/registry/releases"
|
||||
echo " 2. ./mcp-publisher login github"
|
||||
echo " 3. ./mcp-publisher publish"
|
||||
echo " Web UI: https://registry.modelcontextprotocol.io"
|
||||
echo "══════════════════════════════════════════════════════════"
|
||||
exit 0 # Don't fail the workflow — release succeeded
|
||||
}
|
||||
|
||||
- name: Skip notice
|
||||
if: steps.install-publisher.outputs.installed != 'true'
|
||||
run: |
|
||||
echo "mcp-publisher not installed — skipping automated publish"
|
||||
echo "Manual: https://registry.modelcontextprotocol.io"
|
||||
|
||||
# ── Release Summary ──────────────────────────────────────────────────────────
|
||||
|
||||
release-info:
|
||||
needs: [mcp-publish]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
|
||||
steps:
|
||||
- name: Summary
|
||||
run: |
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Release ${RELEASE_TAG} complete! "
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "GitHub Release:"
|
||||
echo " https://github.com/masdevid/mt5-quant/releases/tag/${RELEASE_TAG}"
|
||||
echo ""
|
||||
echo "MCP Registry: io.github.masdevid/mt5-quant"
|
||||
echo ""
|
||||
echo "── MCP Client Registration ──────────────────────────────────"
|
||||
echo ""
|
||||
echo "Claude Code:"
|
||||
echo " claude mcp add io.github.masdevid/mt5-quant -- ~/.local/bin/mt5-quant"
|
||||
echo ""
|
||||
echo "Windsurf (~/.codeium/windsurf/mcp_config.json):"
|
||||
echo ' {"mcpServers": {"io.github.masdevid/mt5-quant": {"command": "~/.local/bin/mt5-quant"}}}'
|
||||
echo ""
|
||||
echo "Cursor / VS Code (~/.cursor/mcp.json):"
|
||||
echo ' {"mcpServers": {"mt5-quant": {"command": "~/.local/bin/mt5-quant"}}}'
|
||||
echo ""
|
||||
echo "─────────────────────────────────────────────────────────────"
|
||||
+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
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# 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
|
||||
- fix: registryType mcpbPackageType → mcpb
|
||||
|
||||
|
||||
## [1.31.1] — 2026-04-22
|
||||
|
||||
- Minor improvements and bug fixes
|
||||
|
||||
Generated
+1
-1
@@ -481,7 +481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mt5-quant"
|
||||
version = "1.30.0"
|
||||
version = "1.32.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+10
-2
@@ -1,9 +1,17 @@
|
||||
[package]
|
||||
name = "mt5-quant"
|
||||
version = "1.30.0"
|
||||
version = "1.32.0"
|
||||
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"
|
||||
maintenance = { status = "actively-developed" }
|
||||
|
||||
[[bin]]
|
||||
name = "mt5-quant"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MT5-Quant
|
||||
|
||||
**MCP server for MT5 strategy development on macOS/Linux.** 85 tools to compile, backtest, analyze, optimize, debug crashes, and manage MQL5 Expert Advisors — no Windows required.
|
||||
**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,22 +91,27 @@ 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 75 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 (75)
|
||||
## MCP Tools (89)
|
||||
|
||||
### Core workflow
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `run_backtest` | Full pipeline: compile → clean → backtest → extract → analyze |
|
||||
| `run_backtest_quick` | Quick backtest using pre-compiled EA (skip compile) |
|
||||
| `run_backtest_only` | Backtest only - just extract raw trades, no analysis |
|
||||
| `launch_backtest` | Fire-and-forget: launch MT5 backtest, poll for completion |
|
||||
| `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 |
|
||||
@@ -83,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)
|
||||
|
||||
@@ -119,7 +156,6 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `verify_setup` | Check Wine/MT5 paths, Wine version, and EA/set file counts |
|
||||
| `get_backtest_status` | Check live progress of a running backtest pipeline |
|
||||
| `get_optimization_status` | Check live state of a background optimization job |
|
||||
| `list_jobs` | All optimization jobs with compact status in one call |
|
||||
|
||||
@@ -140,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
|
||||
|
||||
@@ -149,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
|
||||
|
||||
@@ -180,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
|
||||
|
||||
@@ -240,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**: Full path to binary (e.g., `/Users/name/mt5-quant/target/release/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": "/absolute/path/to/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"]
|
||||
}
|
||||
}
|
||||
|
||||
+1878
-16
File diff suppressed because it is too large
Load Diff
+14
-13
@@ -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
|
||||
```
|
||||
|
||||
@@ -81,7 +81,8 @@ terminal_dir: "~/Library/Application Support/net.metaquotes.wine.metatrader5/dri
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
claude mcp add MT5-Quant -- /path/to/mt5-quant/target/release/mt5-quant
|
||||
claude mcp add io.github.masdevid/mt5-quant -- ~/.local/bin/mt5-quant
|
||||
# Or: claude mcp add mt5-quant -- $(pwd)/mcp-server/bin/mt5-quant
|
||||
```
|
||||
|
||||
Verify:
|
||||
@@ -97,7 +98,7 @@ Add to `~/.codeium/windsurf/mcp_config.json`:
|
||||
{
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"command": "/path/to/mt5-quant"
|
||||
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,7 +112,7 @@ Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
|
||||
{
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"command": "/path/to/mt5-quant"
|
||||
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -135,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"
|
||||
@@ -151,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
|
||||
|
||||
@@ -188,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: Full path to binary (e.g., `/Users/name/mt5-quant/target/release/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": "/absolute/path/to/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": "/path/to/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":"/path/to/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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
-6
@@ -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 @@ Edit `~/.codeium/windsurf/mcp_config.json`:
|
||||
{
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"command": "/Users/masdevid/jobs/mt5-quant/target/release/mt5-quant"
|
||||
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,10 @@ Edit `~/.codeium/windsurf/mcp_config.json`:
|
||||
Or use the automated setup:
|
||||
|
||||
```bash
|
||||
# Install binary to standard location
|
||||
cp mcp-server/bin/mt5-quant ~/.local/bin/
|
||||
|
||||
# Then configure
|
||||
bash scripts/setup.sh
|
||||
```
|
||||
|
||||
@@ -92,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 ""
|
||||
|
||||
Executable
+238
@@ -0,0 +1,238 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/release.sh — Automate version bump, changelog, git tag, and push
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/release.sh patch # 1.31.0 → 1.31.1
|
||||
# bash scripts/release.sh minor # 1.31.0 → 1.32.0
|
||||
# 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")/.."
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
info() { echo -e "${BLUE}▶ $*${NC}"; }
|
||||
ok() { echo -e "${GREEN}✓ $*${NC}"; }
|
||||
warn() { echo -e "${YELLOW}⚠ $*${NC}"; }
|
||||
die() { echo -e "${RED}✗ $*${NC}" >&2; exit 1; }
|
||||
hr() { echo -e "${BLUE}────────────────────────────────────────${NC}"; }
|
||||
|
||||
# ── Argument parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
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=$(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 ────────────────────────────────────────────────────────
|
||||
|
||||
IFS='.' read -r major minor patch_v <<< "$current"
|
||||
|
||||
case "$bump" in
|
||||
major) new="$((major + 1)).0.0" ;;
|
||||
minor) new="${major}.$((minor + 1)).0" ;;
|
||||
patch) new="${major}.${minor}.$((patch_v + 1))" ;;
|
||||
v*.*.*) new="${bump#v}" ;;
|
||||
*.*.*) new="$bump" ;;
|
||||
*) die "Usage: $0 [patch|minor|major|X.Y.Z] [--yes]" ;;
|
||||
esac
|
||||
|
||||
[[ "$new" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || die "Invalid version: $new"
|
||||
|
||||
hr
|
||||
info "Releasing: ${BOLD}$current → $new${NC}"
|
||||
hr
|
||||
|
||||
# ── Confirm ────────────────────────────────────────────────────────────────────
|
||||
|
||||
if [[ "$AUTO_YES" == false ]]; then
|
||||
read -rp "Proceed with release v${new}? [y/N] " confirm
|
||||
[[ "$confirm" =~ ^[Yy]$ ]] || die "Aborted"
|
||||
else
|
||||
info "Auto-confirmed (--yes)"
|
||||
fi
|
||||
|
||||
# ── Check git state ────────────────────────────────────────────────────────────
|
||||
|
||||
info "Checking git state..."
|
||||
if ! git diff --quiet 2>/dev/null || ! git diff --cached --quiet 2>/dev/null; then
|
||||
die "Working tree has uncommitted changes — commit or stash first"
|
||||
fi
|
||||
current_branch=$(git rev-parse --abbrev-ref HEAD)
|
||||
info "Branch: $current_branch"
|
||||
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
info "Bumping Cargo.toml..."
|
||||
if [[ "$(uname)" == "Darwin" ]]; then
|
||||
sed -i '' "s/^version = \"${current}\"/version = \"${new}\"/" Cargo.toml
|
||||
else
|
||||
sed -i "s/^version = \"${current}\"/version = \"${new}\"/" Cargo.toml
|
||||
fi
|
||||
cargo metadata --no-deps --format-version 1 >/dev/null 2>&1 || true
|
||||
ok "Cargo.toml → $new"
|
||||
|
||||
# ── 2. Update server.json ──────────────────────────────────────────────────────
|
||||
|
||||
info "Updating server.json..."
|
||||
NEW_VERSION="$new" python3 - <<'PYEOF'
|
||||
import json, os, re
|
||||
|
||||
version = os.environ['NEW_VERSION']
|
||||
|
||||
with open('server.json') as f:
|
||||
data = json.load(f)
|
||||
|
||||
data['version'] = version
|
||||
|
||||
for pkg in data.get('packages', []):
|
||||
pkg['version'] = version
|
||||
if 'identifier' in pkg:
|
||||
pkg['identifier'] = re.sub(
|
||||
r'/v[0-9]+\.[0-9]+\.[0-9]+/',
|
||||
f'/v{version}/',
|
||||
pkg['identifier']
|
||||
)
|
||||
# SHA256 is computed by CI after building — placeholder signals this
|
||||
pkg['fileSha256'] = 'TBD_CI_WILL_UPDATE'
|
||||
|
||||
with open('server.json', 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write('\n')
|
||||
|
||||
print(f" server.json version={version}, identifier URL updated")
|
||||
PYEOF
|
||||
ok "server.json → $new (SHA256 set by CI)"
|
||||
|
||||
# ── 3. Generate CHANGELOG entry ────────────────────────────────────────────────
|
||||
|
||||
info "Generating CHANGELOG entry..."
|
||||
prev_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
today=$(date +%Y-%m-%d)
|
||||
|
||||
{
|
||||
echo "## [$new] — $today"
|
||||
echo ""
|
||||
if [[ -n "$prev_tag" ]]; then
|
||||
git log "${prev_tag}..HEAD" --pretty=format:"- %s" --no-merges \
|
||||
| grep -v "^- ci:" \
|
||||
| grep -v "^- chore:" \
|
||||
| grep -v "^$" \
|
||||
|| echo "- Minor improvements and bug fixes"
|
||||
else
|
||||
echo "- Initial public release"
|
||||
fi
|
||||
echo ""
|
||||
} > /tmp/release_entry.md
|
||||
|
||||
if [[ -f CHANGELOG.md ]]; then
|
||||
tmp=$(mktemp)
|
||||
head -1 CHANGELOG.md > "$tmp"
|
||||
echo "" >> "$tmp"
|
||||
cat /tmp/release_entry.md >> "$tmp"
|
||||
tail -n +2 CHANGELOG.md >> "$tmp"
|
||||
mv "$tmp" CHANGELOG.md
|
||||
else
|
||||
{ echo "# Changelog"; echo ""; cat /tmp/release_entry.md; } > CHANGELOG.md
|
||||
fi
|
||||
rm -f /tmp/release_entry.md
|
||||
ok "CHANGELOG.md updated"
|
||||
|
||||
# ── 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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
info "Creating release commit..."
|
||||
git add Cargo.toml Cargo.lock server.json CHANGELOG.md
|
||||
git commit -m "release: v${new}
|
||||
|
||||
- Version bump ${current} → ${new}
|
||||
- server.json identifier URL updated (SHA256 set by CI after build)
|
||||
- CHANGELOG.md updated"
|
||||
ok "Release commit created"
|
||||
|
||||
# ── 6. Tag ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
info "Creating annotated tag v${new}..."
|
||||
git tag -a "v${new}" -m "Release v${new}"
|
||||
ok "Tagged v${new}"
|
||||
|
||||
# ── 7. Push (rebase if remote has new commits from CI) ─────────────────────────
|
||||
|
||||
info "Pushing to GitHub..."
|
||||
if ! git push origin "$current_branch" 2>/dev/null; then
|
||||
warn "Push rejected — rebasing on remote (CI may have committed SHA256)..."
|
||||
git fetch origin "$current_branch"
|
||||
|
||||
# Resolve server.json conflict automatically: keep our version
|
||||
if ! git rebase "origin/${current_branch}"; then
|
||||
if git diff --name-only --diff-filter=U | grep -q "server.json"; then
|
||||
python3 - <<'PYEOF'
|
||||
import re
|
||||
with open('server.json') as f:
|
||||
raw = f.read()
|
||||
resolved = re.sub(
|
||||
r'<<<<<<< HEAD.*?=======\n(.*?)>>>>>>> [^\n]+\n',
|
||||
r'\1',
|
||||
raw,
|
||||
flags=re.DOTALL
|
||||
)
|
||||
with open('server.json', 'w') as f:
|
||||
f.write(resolved)
|
||||
PYEOF
|
||||
git add server.json
|
||||
GIT_EDITOR=true git rebase --continue
|
||||
else
|
||||
git rebase --abort
|
||||
die "Rebase failed with unexpected conflicts — push manually"
|
||||
fi
|
||||
fi
|
||||
|
||||
git push origin "$current_branch"
|
||||
fi
|
||||
|
||||
# Push tag (delete and re-push if rebase moved the commit)
|
||||
git push origin "v${new}" 2>/dev/null || {
|
||||
git push origin ":refs/tags/v${new}" 2>/dev/null || true
|
||||
git tag -f -a "v${new}" -m "Release v${new}"
|
||||
git push origin "v${new}"
|
||||
}
|
||||
|
||||
ok "Pushed — GitHub Actions triggered"
|
||||
|
||||
# ── Done ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
hr
|
||||
echo ""
|
||||
echo -e "${GREEN}${BOLD} Release v${new} kicked off!${NC}"
|
||||
echo ""
|
||||
echo -e " Actions: https://github.com/masdevid/mt5-quant/actions"
|
||||
echo -e " Release: https://github.com/masdevid/mt5-quant/releases/tag/v${new}"
|
||||
echo ""
|
||||
echo -e " ${YELLOW}CI will compute the MCP package SHA256 and${NC}"
|
||||
echo -e " ${YELLOW}commit it back to server.json automatically.${NC}"
|
||||
echo ""
|
||||
hr
|
||||
+17
-9
@@ -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.
|
||||
|
||||
@@ -1024,14 +1024,20 @@ _register_with_platform() {
|
||||
local platform="$1"
|
||||
local use_binary="${2:-false}"
|
||||
|
||||
# Determine command path (binary for Windsurf/Cursor, Python for Claude)
|
||||
# Determine command path - use standard installation location
|
||||
local cmd_path
|
||||
if $use_binary && [[ -f "${REPO_DIR}/target/release/mt5-quant" ]]; then
|
||||
local default_install="$HOME/.local/bin/mt5-quant"
|
||||
|
||||
if [[ -f "$default_install" ]]; then
|
||||
cmd_path="$default_install"
|
||||
elif [[ -f "${REPO_DIR}/target/release/mt5-quant" ]]; then
|
||||
# Fallback to project build if standard location not found
|
||||
cmd_path="${REPO_DIR}/target/release/mt5-quant"
|
||||
elif [[ -f "${REPO_DIR}/mt5-quant" ]]; then
|
||||
cmd_path="${REPO_DIR}/mt5-quant"
|
||||
_warn "Using project build at $cmd_path"
|
||||
_warn "Consider installing to standard location: cp $cmd_path ~/.local/bin/"
|
||||
else
|
||||
cmd_path="python3 ${REPO_DIR}/server/main.py"
|
||||
_fail "mt5-quant not found at $default_install or ${REPO_DIR}/target/release/"
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$platform" in
|
||||
@@ -1066,8 +1072,10 @@ if os.path.exists(config_path):
|
||||
if 'mcpServers' not in data:
|
||||
data['mcpServers'] = {}
|
||||
|
||||
data['mcpServers']['mt5-quant'] = {
|
||||
'command': '$cmd_path'
|
||||
data['mcpServers']['io.github.masdevid/mt5-quant'] = {
|
||||
'command': '$cmd_path',
|
||||
'disabled': False,
|
||||
'registry': 'io.github.masdevid/mt5-quant'
|
||||
}
|
||||
|
||||
with open(config_path, 'w') as f:
|
||||
@@ -1193,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
|
||||
|
||||
+5
-5
@@ -2,18 +2,18 @@
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.masdevid/mt5-quant",
|
||||
"title": "MT5-Quant",
|
||||
"description": "MCP server for MT5 strategy development with 85 tools. Compile, backtest, analyze, optimize, and debug MQL5 EAs on macOS/Linux. Includes Wine/MT5 crash diagnostics.",
|
||||
"description": "MT5 strategy development with 87 tools. Backtest, optimize, debug MQL5 EAs on macOS/Linux.",
|
||||
"repository": {
|
||||
"url": "https://github.com/masdevid/mt5-quant",
|
||||
"source": "github"
|
||||
},
|
||||
"version": "1.30.0",
|
||||
"version": "1.31.5",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "mcpb",
|
||||
"version": "1.30.0",
|
||||
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.30.0/mcp-mt5-quant-macos-arm64.tar.gz",
|
||||
"fileSha256": "PLACEHOLDER_SHA256",
|
||||
"version": "1.31.5",
|
||||
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.31.5/mcp-mt5-quant-macos-arm64.tar.gz",
|
||||
"fileSha256": "97e05d3bc97270866d5736660bd19f071fd0fabbc474ba481825d69ec2b4bdf5",
|
||||
"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)]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout as tokio_timeout;
|
||||
|
||||
use crate::models::Config;
|
||||
|
||||
@@ -28,7 +29,11 @@ impl MqlCompiler {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub fn compile(&self, source_path: &str) -> Result<CompileResult> {
|
||||
pub async fn compile(&self, source_path: &str) -> Result<CompileResult> {
|
||||
self.compile_with_timeout(source_path, Duration::from_secs(120)).await
|
||||
}
|
||||
|
||||
pub async fn compile_with_timeout(&self, source_path: &str, timeout: Duration) -> Result<CompileResult> {
|
||||
let source_path = Path::new(source_path);
|
||||
if !source_path.exists() {
|
||||
return Err(anyhow!("Source file not found: {}", source_path.display()));
|
||||
@@ -61,7 +66,7 @@ impl MqlCompiler {
|
||||
let staged_mq5 = &sync.dest_mq5;
|
||||
tracing::info!("Staged {} file(s) to: {}", sync.files_copied, staged_mq5.display());
|
||||
|
||||
self.run_metaeditor(wine_exe, &wine_prefix, &metaeditor, staged_mq5)?;
|
||||
self.run_metaeditor_with_timeout(wine_exe, &wine_prefix, &metaeditor, staged_mq5, timeout).await?;
|
||||
|
||||
// /log flag (no path) writes log adjacent to source: {ea_name}.log
|
||||
let log_path = staged_mq5.with_extension("log");
|
||||
@@ -197,15 +202,16 @@ impl MqlCompiler {
|
||||
Ok(SyncStats { dest_mq5, files_copied })
|
||||
}
|
||||
|
||||
/// Run MetaEditor to compile `source_mq5`.
|
||||
/// Run MetaEditor to compile `source_mq5` with timeout.
|
||||
/// Uses Unix host path for /compile: and bare /log flag (writes log adjacent to source).
|
||||
/// Shell script intermediary required on macOS to preserve DYLD_* vars past SIP.
|
||||
fn run_metaeditor(
|
||||
async fn run_metaeditor_with_timeout(
|
||||
&self,
|
||||
wine_exe: &str,
|
||||
wine_prefix: &Path,
|
||||
metaeditor: &Path,
|
||||
source_mq5: &Path,
|
||||
timeout: Duration,
|
||||
) -> Result<()> {
|
||||
let mt5_dir = metaeditor.parent().unwrap_or(metaeditor);
|
||||
|
||||
@@ -242,16 +248,24 @@ impl MqlCompiler {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
Command::new("/bin/sh").arg(&script_path).output()?;
|
||||
let compile_future = tokio::process::Command::new("/bin/sh")
|
||||
.arg(&script_path)
|
||||
.output();
|
||||
let result = tokio_timeout(timeout, compile_future).await
|
||||
.map_err(|_| anyhow!("Compilation timed out after {} seconds", timeout.as_secs()))?;
|
||||
result?;
|
||||
} else {
|
||||
Command::new(wine_exe)
|
||||
let compile_future = tokio::process::Command::new(wine_exe)
|
||||
.arg(metaeditor)
|
||||
.arg(format!("/compile:{}", source_mq5.display()))
|
||||
.arg("/log")
|
||||
.env("WINEPREFIX", wine_prefix)
|
||||
.env("WINEDEBUG", "-all")
|
||||
.current_dir(mt5_dir)
|
||||
.output()?;
|
||||
.output();
|
||||
let result = tokio_timeout(timeout, compile_future).await
|
||||
.map_err(|_| anyhow!("Compilation timed out after {} seconds", timeout.as_secs()))?;
|
||||
result?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+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();
|
||||
|
||||
|
||||
+60
-11
@@ -1,9 +1,11 @@
|
||||
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};
|
||||
|
||||
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 +15,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 +33,37 @@ 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);
|
||||
}
|
||||
|
||||
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 +281,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(),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
// Re-export chrono for BacktestJob
|
||||
use chrono;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Report {
|
||||
@@ -31,6 +34,8 @@ pub struct PipelineMetadata {
|
||||
pub report_dir: String,
|
||||
pub duration_seconds: i64,
|
||||
pub files: FilePaths,
|
||||
#[serde(default)]
|
||||
pub no_trades: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -48,6 +53,9 @@ pub struct BacktestStatus {
|
||||
pub elapsed_seconds: i64,
|
||||
pub is_complete: bool,
|
||||
pub message: String,
|
||||
pub report_dir: Option<String>,
|
||||
pub mt5_running: Option<bool>,
|
||||
pub report_found: Option<bool>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -60,4 +68,43 @@ pub enum PipelineStage {
|
||||
Extract,
|
||||
Analyze,
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Track a running backtest job for fire-and-poll pattern
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BacktestJob {
|
||||
pub report_id: String,
|
||||
pub report_dir: String,
|
||||
pub expert: String,
|
||||
pub symbol: String,
|
||||
pub timeframe: String,
|
||||
pub launched_at: String,
|
||||
pub mt5_pid: Option<u32>,
|
||||
pub expected_report_path: String,
|
||||
pub timeout_seconds: u64,
|
||||
}
|
||||
|
||||
impl BacktestJob {
|
||||
pub fn new(
|
||||
report_id: String,
|
||||
report_dir: String,
|
||||
expert: String,
|
||||
symbol: String,
|
||||
timeframe: String,
|
||||
expected_report_path: String,
|
||||
timeout_seconds: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
report_id,
|
||||
report_dir,
|
||||
expert,
|
||||
symbol,
|
||||
timeframe,
|
||||
launched_at: chrono::Utc::now().to_rfc3339(),
|
||||
mt5_pid: None,
|
||||
expected_report_path,
|
||||
timeout_seconds,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+351
-81
@@ -1,21 +1,26 @@
|
||||
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};
|
||||
use crate::compile::MqlCompiler;
|
||||
use crate::models::config::Config;
|
||||
use crate::models::report::{PipelineMetadata, FilePaths};
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +94,7 @@ impl BacktestPipeline {
|
||||
|
||||
if !params.skip_compile {
|
||||
self.log_progress(&progress_log, "COMPILE").await;
|
||||
self.compile_ea(¶ms.expert).await?;
|
||||
self.compile_ea(¶ms.expert, params.timeout).await?;
|
||||
}
|
||||
|
||||
if !params.skip_clean {
|
||||
@@ -90,6 +111,13 @@ impl BacktestPipeline {
|
||||
&report_dir.to_string_lossy(),
|
||||
)?;
|
||||
|
||||
// Handle case where EA didn't trade - no deals generated
|
||||
if extraction.deals.is_empty() {
|
||||
tracing::warn!("Backtest completed but no deals were generated - EA did not trade during this period");
|
||||
let warning_path = report_dir.join("NO_TRADES_WARNING.txt");
|
||||
let _ = fs::write(&warning_path, "Warning: No deals were generated during this backtest.\nThe EA did not execute any trades during the specified date range.\n");
|
||||
}
|
||||
|
||||
// Move equity chart images to OS temp dir, then delete the HTML report.
|
||||
let charts_dir = self.relocate_charts(&report_path, &report_id).await;
|
||||
let _ = fs::remove_file(&report_path);
|
||||
@@ -108,10 +136,10 @@ impl BacktestPipeline {
|
||||
self.log_progress(&progress_log, "DONE").await;
|
||||
|
||||
let duration = (chrono::Utc::now() - start_time).num_seconds();
|
||||
self.save_metadata(¶ms, &report_dir, duration).await?;
|
||||
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,
|
||||
@@ -122,14 +150,224 @@ 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 {
|
||||
"Backtest completed successfully".to_string()
|
||||
};
|
||||
|
||||
Ok(PipelineResult {
|
||||
success: true,
|
||||
report_dir,
|
||||
duration_seconds: duration,
|
||||
message: "Backtest completed successfully".to_string(),
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
/// Launch backtest in fire-and-forget mode: compile, clean, launch MT5, return immediately.
|
||||
/// Returns a BacktestJob that can be used with get_backtest_status to poll for completion.
|
||||
pub async fn launch_backtest(&self, params: BacktestParams) -> Result<BacktestJob> {
|
||||
let _start_time = chrono::Utc::now();
|
||||
let report_id = self.generate_report_id(¶ms);
|
||||
let report_dir = self.config.reports_dir().join(&report_id);
|
||||
|
||||
fs::create_dir_all(&report_dir)?;
|
||||
|
||||
let progress_log = report_dir.join("progress.log");
|
||||
self.log_progress(&progress_log, "START").await;
|
||||
|
||||
if !params.skip_compile {
|
||||
self.log_progress(&progress_log, "COMPILE").await;
|
||||
self.compile_ea(¶ms.expert, params.timeout).await?;
|
||||
}
|
||||
|
||||
if !params.skip_clean {
|
||||
self.log_progress(&progress_log, "CLEAN").await;
|
||||
self.clean_cache(¶ms.expert).await?;
|
||||
}
|
||||
|
||||
self.log_progress(&progress_log, "BACKTEST").await;
|
||||
|
||||
// Get MT5 paths
|
||||
let mt5_dir = self.config.mt5_dir()
|
||||
.ok_or_else(|| anyhow!("MT5 directory not configured"))?;
|
||||
let wine_exe = self.config.wine_executable.as_ref()
|
||||
.ok_or_else(|| anyhow!("wine_executable not configured"))?;
|
||||
let wine_prefix = mt5_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.ok_or_else(|| anyhow!("Could not determine Wine prefix from terminal_dir"))?;
|
||||
let reports_dir = mt5_dir.join("reports");
|
||||
fs::create_dir_all(&reports_dir)?;
|
||||
|
||||
// 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())?;
|
||||
self.update_terminal_ini(¶ms, &report_id)?;
|
||||
|
||||
// Kill any running MT5
|
||||
self.kill_mt5().await?;
|
||||
|
||||
// Launch MT5 (fire and forget)
|
||||
let mut cmd = self.build_wine_launch(wine_exe, &wine_prefix)?;
|
||||
let child = cmd.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
let pid = child.id();
|
||||
tracing::info!("MT5 launched with PID {:?} for backtest {}", pid, report_id);
|
||||
|
||||
// Create and save the job tracking file
|
||||
let expected_report = reports_dir.join(format!("{}.htm", report_id));
|
||||
let job = BacktestJob::new(
|
||||
report_id.clone(),
|
||||
report_dir.to_string_lossy().to_string(),
|
||||
params.expert.clone(),
|
||||
params.symbol.clone(),
|
||||
params.timeframe.clone(),
|
||||
expected_report.to_string_lossy().to_string(),
|
||||
params.timeout,
|
||||
);
|
||||
|
||||
// Save job info for polling
|
||||
let job_path = report_dir.join("job.json");
|
||||
fs::write(&job_path, serde_json::to_string_pretty(&job)?)?;
|
||||
|
||||
// Save initial metadata
|
||||
self.save_metadata(¶ms, &report_dir, 0, false).await?;
|
||||
|
||||
// Register in DB as "running"
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
if let Err(e) = db.init() {
|
||||
tracing::warn!("Failed to init report DB: {}", e);
|
||||
}
|
||||
|
||||
// Spawn background task to monitor completion and update status
|
||||
let report_dir_clone = report_dir.clone();
|
||||
let expected_report_clone = expected_report.clone();
|
||||
let timeout_secs = params.timeout;
|
||||
let report_id_clone = report_id.clone();
|
||||
let notification_callback = self.notification_callback.clone();
|
||||
tokio::spawn(async move {
|
||||
Self::monitor_backtest_completion(
|
||||
report_dir_clone,
|
||||
expected_report_clone,
|
||||
timeout_secs,
|
||||
report_id_clone,
|
||||
notification_callback,
|
||||
).await;
|
||||
});
|
||||
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// Background task to monitor backtest completion and update status file.
|
||||
async fn monitor_backtest_completion(
|
||||
report_dir: PathBuf,
|
||||
expected_report: PathBuf,
|
||||
timeout_secs: u64,
|
||||
report_id: String,
|
||||
notification_callback: Option<NotificationCallback>,
|
||||
) {
|
||||
let start = tokio::time::Instant::now();
|
||||
let deadline = start + Duration::from_secs(timeout_secs);
|
||||
let grace_period = Duration::from_secs(30);
|
||||
let poll_start = std::time::SystemTime::now();
|
||||
|
||||
loop {
|
||||
let _elapsed = start.elapsed().as_secs();
|
||||
|
||||
// Check for report file
|
||||
for ext in &[".htm", ".htm.xml", ".html"] {
|
||||
let candidate = expected_report.with_extension(ext.trim_start_matches('.'));
|
||||
if candidate.exists() {
|
||||
tracing::info!("Backtest {} completed: report found at {}", report_id, candidate.display());
|
||||
Self::update_job_status(&report_dir, "completed", Some(candidate.to_string_lossy().to_string())).await;
|
||||
if let Some(ref callback) = notification_callback {
|
||||
callback("backtest_completed", json!({
|
||||
"report_id": report_id,
|
||||
"report_path": candidate.to_string_lossy().to_string(),
|
||||
"status": "completed"
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check process liveness after grace period
|
||||
let in_grace = start.elapsed() <= grace_period;
|
||||
let mt5_alive = Self::is_mt5_running();
|
||||
|
||||
if !in_grace && !mt5_alive {
|
||||
// MT5 exited without report - check for any newer report
|
||||
if let Some(path) = Self::find_newest_report(expected_report.parent().unwrap(), poll_start) {
|
||||
tracing::info!("Backtest {} completed: found fallback report {}", report_id, path.display());
|
||||
Self::update_job_status(&report_dir, "completed", Some(path.to_string_lossy().to_string())).await;
|
||||
if let Some(ref callback) = notification_callback {
|
||||
callback("backtest_completed", json!({
|
||||
"report_id": report_id,
|
||||
"report_path": path.to_string_lossy().to_string(),
|
||||
"status": "completed"
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
tracing::warn!("Backtest {} failed: MT5 exited without producing a report", report_id);
|
||||
Self::update_job_status(&report_dir, "failed", None).await;
|
||||
if let Some(ref callback) = notification_callback {
|
||||
callback("backtest_failed", json!({
|
||||
"report_id": report_id,
|
||||
"status": "failed",
|
||||
"reason": "MT5 exited without producing a report"
|
||||
}));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
tracing::warn!("Backtest {} timed out after {} seconds", report_id, timeout_secs);
|
||||
Self::update_job_status(&report_dir, "timeout", None).await;
|
||||
if let Some(ref callback) = notification_callback {
|
||||
callback("backtest_timeout", json!({
|
||||
"report_id": report_id,
|
||||
"status": "timeout",
|
||||
"timeout_seconds": timeout_secs
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update job status in job.json file.
|
||||
async fn update_job_status(report_dir: &Path, status: &str, report_path: Option<String>) {
|
||||
let job_path = report_dir.join("job.json");
|
||||
if let Ok(job_json) = fs::read_to_string(&job_path) {
|
||||
if let Ok(mut job) = serde_json::from_str::<serde_json::Value>(&job_json) {
|
||||
job["status"] = serde_json::Value::String(status.to_string());
|
||||
job["completed_at"] = serde_json::Value::String(chrono::Utc::now().to_rfc3339());
|
||||
if let Some(path) = report_path {
|
||||
job["actual_report_path"] = serde_json::Value::String(path);
|
||||
}
|
||||
if let Ok(updated) = serde_json::to_string_pretty(&job) {
|
||||
let _ = fs::write(&job_path, updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Move equity chart images (*.png, *.gif) from MT5's reports dir to OS temp,
|
||||
/// returning the temp path if any images were found.
|
||||
async fn relocate_charts(&self, html_path: &Path, report_id: &str) -> Option<PathBuf> {
|
||||
@@ -191,11 +429,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 {
|
||||
@@ -229,10 +467,13 @@ 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) -> Result<()> {
|
||||
async fn compile_ea(&self, expert: &str, timeout_secs: u64) -> Result<()> {
|
||||
let mut search_paths = vec![
|
||||
PathBuf::from(&self.config.get("project_dir")).join("src/experts").join(format!("{}.mq5", expert)),
|
||||
PathBuf::from(&self.config.get("project_dir")).join("src").join(format!("{}.mq5", expert)),
|
||||
@@ -252,7 +493,8 @@ impl BacktestPipeline {
|
||||
.find(|p| p.exists())
|
||||
.ok_or_else(|| anyhow!("Cannot find {}.mq5 — searched project_dir and MT5 Experts dir", expert))?;
|
||||
|
||||
let result = self.compiler.compile(&source_path.to_string_lossy())?;
|
||||
let timeout = std::time::Duration::from_secs(timeout_secs.min(300)); // Max 5 min for compile
|
||||
let result = self.compiler.compile_with_timeout(&source_path.to_string_lossy(), timeout).await?;
|
||||
|
||||
if !result.success {
|
||||
return Err(anyhow!(
|
||||
@@ -352,12 +594,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)?;
|
||||
@@ -378,8 +616,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 —
|
||||
@@ -424,15 +664,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]]))
|
||||
@@ -445,21 +691,31 @@ 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))
|
||||
.unwrap_or_default();
|
||||
|
||||
let updates: &[(&str, String)] = &[
|
||||
("Expert", self.resolve_expert_path(¶ms.expert)),
|
||||
("Expert", expert_path),
|
||||
("Symbol", params.symbol.clone()),
|
||||
("Period", period.to_string()),
|
||||
("DateRange", "3".into()),
|
||||
@@ -478,31 +734,15 @@ 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())
|
||||
}
|
||||
|
||||
/// 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);
|
||||
@@ -519,7 +759,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));
|
||||
}
|
||||
@@ -538,7 +777,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));
|
||||
@@ -547,19 +785,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/
|
||||
@@ -572,11 +824,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\
|
||||
@@ -592,43 +846,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 {
|
||||
@@ -645,11 +897,17 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,11 +955,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(())
|
||||
}
|
||||
@@ -761,7 +1030,7 @@ impl BacktestPipeline {
|
||||
let _ = fs::write(log_path, line);
|
||||
}
|
||||
|
||||
async fn save_metadata(&self, params: &BacktestParams, report_dir: &Path, duration: i64) -> Result<()> {
|
||||
async fn save_metadata(&self, params: &BacktestParams, report_dir: &Path, duration: i64, no_trades: bool) -> Result<()> {
|
||||
let metadata = PipelineMetadata {
|
||||
expert: params.expert.clone(),
|
||||
symbol: params.symbol.clone(),
|
||||
@@ -781,6 +1050,7 @@ impl BacktestPipeline {
|
||||
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,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&metadata)?;
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,87 @@ use serde_json::{json, Value};
|
||||
pub fn tool_run_backtest() -> Value {
|
||||
json!({
|
||||
"name": "run_backtest",
|
||||
"description": "Run a complete MT5 backtest pipeline: compile → clean cache → backtest → extract → analyze",
|
||||
"description": "Full backtest pipeline: compile EA → clean cache → run backtest → extract results → analyze. Use this when you have modified the EA source code.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert"],
|
||||
"properties": {
|
||||
"expert": { "type": "string", "description": "EA name without path or extension" },
|
||||
"symbol": { "type": "string", "description": "Trading symbol (default: from config or first available)" },
|
||||
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD (default: past complete month)" },
|
||||
"to_date": { "type": "string", "description": "End date YYYY.MM.DD (default: past complete month)" },
|
||||
"timeframe": { "type": "string", "enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1"], "description": "Chart timeframe (default: M5)" },
|
||||
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" },
|
||||
"model": { "type": "integer", "enum": [0, 1, 2], "description": "Tick model: 0=Every tick, 1=OHLC, 2=Open prices" },
|
||||
"set_file": { "type": "string", "description": "Path to .set parameter file for EA inputs" },
|
||||
"skip_compile": { "type": "boolean", "description": "Skip compilation (use existing .ex5)" },
|
||||
"skip_clean": { "type": "boolean", "description": "Skip cache cleaning" },
|
||||
"skip_analyze": { "type": "boolean", "description": "Skip analysis phase" },
|
||||
"deep": { "type": "boolean", "description": "Run deep analysis with extra metrics" },
|
||||
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest completes" },
|
||||
"kill_existing": { "type": "boolean", "description": "Kill any running MT5 instance first" },
|
||||
"timeout": { "type": "integer", "description": "Max wait time in seconds (default: 900)" },
|
||||
"gui": { "type": "boolean", "description": "Enable MT5 visualization window" },
|
||||
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10, set to 0 for default 10s)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_run_backtest_quick() -> Value {
|
||||
json!({
|
||||
"name": "run_backtest_quick",
|
||||
"description": "Quick backtest using pre-compiled EA: clean cache → run backtest → extract → analyze. Skips compilation. Use when EA code hasn't changed.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert"],
|
||||
"properties": {
|
||||
"expert": { "type": "string", "description": "EA name without path or extension (must have .ex5 compiled)" },
|
||||
"symbol": { "type": "string", "description": "Trading symbol (default: from config or first available)" },
|
||||
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD (default: past complete month)" },
|
||||
"to_date": { "type": "string", "description": "End date YYYY.MM.DD (default: past complete month)" },
|
||||
"timeframe": { "type": "string", "enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1"], "description": "Chart timeframe (default: M5)" },
|
||||
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" },
|
||||
"model": { "type": "integer", "enum": [0, 1, 2], "description": "Tick model" },
|
||||
"set_file": { "type": "string", "description": "Path to .set parameter file for EA inputs" },
|
||||
"deep": { "type": "boolean", "description": "Run deep analysis" },
|
||||
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest" },
|
||||
"timeout": { "type": "integer", "description": "Max wait time in seconds (default: 900)" },
|
||||
"gui": { "type": "boolean", "description": "Enable MT5 visualization" },
|
||||
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_run_backtest_only() -> Value {
|
||||
json!({
|
||||
"name": "run_backtest_only",
|
||||
"description": "Backtest only: just run backtest and extract results. No compile, no analysis. Fastest option when you just need raw trade data.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert"],
|
||||
"properties": {
|
||||
"expert": { "type": "string", "description": "EA name without path or extension (must have .ex5 compiled)" },
|
||||
"symbol": { "type": "string", "description": "Trading symbol (default: from config)" },
|
||||
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD" },
|
||||
"to_date": { "type": "string", "description": "End date YYYY.MM.DD" },
|
||||
"timeframe": { "type": "string", "enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1"], "description": "Chart timeframe (default: M5)" },
|
||||
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" },
|
||||
"model": { "type": "integer", "enum": [0, 1, 2], "description": "Tick model" },
|
||||
"set_file": { "type": "string", "description": "Path to .set parameter file" },
|
||||
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest" },
|
||||
"timeout": { "type": "integer", "description": "Max wait time (default: 900)" },
|
||||
"gui": { "type": "boolean", "description": "Enable MT5 visualization" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_launch_backtest() -> Value {
|
||||
json!({
|
||||
"name": "launch_backtest",
|
||||
"description": "Launch MT5 backtest in fire-and-forget mode: compile → clean cache → launch MT5 backtest, then return immediately. Use get_backtest_status to poll for completion.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert"],
|
||||
@@ -18,12 +98,9 @@ pub fn tool_run_backtest() -> Value {
|
||||
"set_file": { "type": "string", "description": "Path to .set parameter file" },
|
||||
"skip_compile": { "type": "boolean" },
|
||||
"skip_clean": { "type": "boolean" },
|
||||
"skip_analyze": { "type": "boolean" },
|
||||
"deep": { "type": "boolean", "description": "Run deep analysis" },
|
||||
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest" },
|
||||
"kill_existing": { "type": "boolean" },
|
||||
"timeout": { "type": "integer" },
|
||||
"gui": { "type": "boolean" }
|
||||
"timeout": { "type": "integer", "description": "Max time in seconds to wait for backtest (default: 900)" },
|
||||
"gui": { "type": "boolean", "description": "Enable visualization during backtest" },
|
||||
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -12,9 +12,12 @@ pub mod utility;
|
||||
|
||||
pub fn get_tools_list() -> Value {
|
||||
let tools = vec![
|
||||
// Backtest
|
||||
backtest::tool_run_backtest(),
|
||||
backtest::tool_get_backtest_status(),
|
||||
// Backtest - Granular options
|
||||
backtest::tool_run_backtest(), // Full pipeline: compile + clean + backtest + extract + analyze
|
||||
backtest::tool_run_backtest_quick(), // Quick: skip compile, do clean + backtest + extract + analyze
|
||||
backtest::tool_run_backtest_only(), // Minimal: skip compile, do clean + backtest + extract only
|
||||
backtest::tool_launch_backtest(), // Fire-and-forget: compile + clean + launch MT5
|
||||
backtest::tool_get_backtest_status(), // Poll for completion
|
||||
backtest::tool_cache_status(),
|
||||
backtest::tool_clean_cache(),
|
||||
// Optimization
|
||||
@@ -60,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(),
|
||||
@@ -107,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)
|
||||
|
||||
@@ -3,17 +3,17 @@ use serde_json::{json, Value};
|
||||
pub fn tool_run_optimization() -> Value {
|
||||
json!({
|
||||
"name": "run_optimization",
|
||||
"description": "Launch MT5 genetic parameter optimization",
|
||||
"description": "Launch MT5 genetic parameter optimization in fire-and-forget mode. Returns immediately with job_id. Use get_optimization_status to poll for completion. Optimization typically runs for 2-6 hours.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert", "set_file", "from_date", "to_date"],
|
||||
"properties": {
|
||||
"expert": { "type": "string" },
|
||||
"set_file": { "type": "string" },
|
||||
"symbol": { "type": "string" },
|
||||
"from_date": { "type": "string" },
|
||||
"to_date": { "type": "string" },
|
||||
"deposit": { "type": "integer" }
|
||||
"expert": { "type": "string", "description": "EA name without path or extension" },
|
||||
"set_file": { "type": "string", "description": "Path to .set file with parameter ranges for optimization" },
|
||||
"symbol": { "type": "string", "description": "Trading symbol (default: XAUUSD)" },
|
||||
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD" },
|
||||
"to_date": { "type": "string", "description": "End date YYYY.MM.DD" },
|
||||
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -22,12 +22,12 @@ pub fn tool_run_optimization() -> Value {
|
||||
pub fn tool_get_optimization_status() -> Value {
|
||||
json!({
|
||||
"name": "get_optimization_status",
|
||||
"description": "Check progress of a running optimization job",
|
||||
"description": "Check progress of a running optimization job. Poll periodically until status shows 'completed'.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["job_id"],
|
||||
"properties": {
|
||||
"job_id": { "type": "string" }
|
||||
"job_id": { "type": "string", "description": "Job ID returned by run_optimization" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -36,14 +36,14 @@ pub fn tool_get_optimization_status() -> Value {
|
||||
pub fn tool_get_optimization_results() -> Value {
|
||||
json!({
|
||||
"name": "get_optimization_results",
|
||||
"description": "Parse completed MT5 optimization results",
|
||||
"description": "Parse completed MT5 optimization results and find best parameter combinations",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": { "type": "string" },
|
||||
"report_file": { "type": "string" },
|
||||
"dd_threshold": { "type": "number" },
|
||||
"top_n": { "type": "integer" }
|
||||
"job_id": { "type": "string", "description": "Job ID to parse results for" },
|
||||
"report_file": { "type": "string", "description": "Direct path to optimization report XML file" },
|
||||
"dd_threshold": { "type": "number", "description": "Max drawdown percentage filter" },
|
||||
"top_n": { "type": "integer", "description": "Number of top passes to return (default: 30)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -52,7 +52,7 @@ pub fn tool_get_optimization_results() -> Value {
|
||||
pub fn tool_list_jobs() -> Value {
|
||||
json!({
|
||||
"name": "list_jobs",
|
||||
"description": "List running and completed optimization jobs",
|
||||
"description": "List all running and completed optimization jobs with their status",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+190
-522
@@ -6,635 +6,303 @@ use std::path::Path;
|
||||
use crate::analytics::DealAnalyzer;
|
||||
use crate::models::deals::Deal;
|
||||
use crate::models::metrics::Metrics;
|
||||
use crate::models::Config;
|
||||
use crate::storage::ReportDb;
|
||||
|
||||
/// Helper to load deals and metrics from report directory
|
||||
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");
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
if !deals_csv.exists() {
|
||||
return Err(anyhow::anyhow!("deals.csv not found in {}", report_dir));
|
||||
}
|
||||
fn required_str<'a>(args: &'a Value, key: &str) -> Result<&'a str> {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("{} is required", key))
|
||||
}
|
||||
|
||||
let deals = read_deals_from_csv(&deals_csv)?;
|
||||
|
||||
let metrics = if metrics_json.exists() {
|
||||
let content = fs::read_to_string(&metrics_json)?;
|
||||
serde_json::from_str(&content)?
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
/// 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()?;
|
||||
|
||||
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 = 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(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 = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
let (deals, metrics, analyzer, report_dir) = prepare_analysis(args)?;
|
||||
|
||||
let (deals, metrics) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
|
||||
// Check if specific analytics requested
|
||||
let requested: Option<HashSet<String>> = args.get("analytics")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect());
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect());
|
||||
|
||||
let top_losses_limit = args.get("top_losses_limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
|
||||
|
||||
let all = requested.is_none();
|
||||
let req = |name: &str| all || requested.as_ref().map(|s| s.contains(name)).unwrap_or(false);
|
||||
|
||||
// Build selective result
|
||||
let mut result = json!({});
|
||||
|
||||
if req("monthly_pnl") || all {
|
||||
result["monthly"] = json!(analyzer.monthly_pnl(&deals));
|
||||
}
|
||||
if req("drawdown_events") || all {
|
||||
result["dd_events"] = json!(analyzer.reconstruct_dd_events(&deals, &metrics));
|
||||
}
|
||||
if req("top_losses") || all {
|
||||
result["top_losses"] = json!(analyzer.top_losses(&deals, top_losses_limit));
|
||||
}
|
||||
if req("loss_sequences") || all {
|
||||
result["loss_sequences"] = json!(analyzer.loss_sequences(&deals));
|
||||
}
|
||||
if req("position_pairs") || all {
|
||||
result["position_pairs"] = json!(analyzer.position_pairs(&deals));
|
||||
}
|
||||
if req("direction_bias") || all {
|
||||
result["direction_bias"] = json!(analyzer.direction_bias(&deals));
|
||||
}
|
||||
if req("streak_analysis") || all {
|
||||
result["streak_analysis"] = json!(analyzer.streak_analysis(&deals));
|
||||
}
|
||||
if req("concurrent_peak") || all {
|
||||
result["concurrent_peak"] = json!(analyzer.concurrent_peak(&deals));
|
||||
}
|
||||
if req("monthly_pnl") { result["monthly"] = json!(analyzer.monthly_pnl(&deals)); }
|
||||
if req("drawdown_events") { result["dd_events"] = json!(analyzer.reconstruct_dd_events(&deals, &metrics)); }
|
||||
if req("top_losses") { result["top_losses"] = json!(analyzer.top_losses(&deals, top_losses_limit)); }
|
||||
if req("loss_sequences") { result["loss_sequences"] = json!(analyzer.loss_sequences(&deals)); }
|
||||
if req("position_pairs") { result["position_pairs"] = json!(analyzer.position_pairs(&deals)); }
|
||||
if req("direction_bias") { result["direction_bias"] = json!(analyzer.direction_bias(&deals)); }
|
||||
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(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"analysis_file": analysis_path.to_string_lossy(),
|
||||
"analytics_run": requested.map(|s| s.iter().cloned().collect::<Vec<_>>()).unwrap_or_else(|| vec!["all".to_string()]),
|
||||
"summary": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
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)
|
||||
Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"analysis_file": analysis_path.to_string_lossy(),
|
||||
"analytics_run": requested.map(|s| s.iter().cloned().collect::<Vec<_>>()).unwrap_or_else(|| vec!["all".to_string()]),
|
||||
"summary": result,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn handle_compare_baseline(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
let (_, _, _, 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(json!({
|
||||
"content": [{ "type": "text", "text": "No baseline.json found in config/" }],
|
||||
"isError": false
|
||||
}));
|
||||
return Ok(ok_response(json!("No baseline.json found in config/")));
|
||||
}
|
||||
|
||||
let baseline: Value = serde_json::from_str(&fs::read_to_string(baseline_path)?)?;
|
||||
let current: Value = serde_json::from_str(&fs::read_to_string(metrics_path)?)?;
|
||||
|
||||
let comparison = json!({
|
||||
Ok(ok_response(json!({
|
||||
"baseline": baseline,
|
||||
"current": current,
|
||||
"improvements": {
|
||||
"profit": current.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0)
|
||||
"profit": current.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0)
|
||||
- baseline.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"drawdown": current.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0)
|
||||
- baseline.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": comparison.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
})))
|
||||
}
|
||||
|
||||
// === Granular Analytics Handlers ===
|
||||
// ── Granular analytics handlers ───────────────────────────────────────────────
|
||||
|
||||
pub async fn handle_analyze_monthly_pnl(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.monthly_pnl(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"monthly_pnl": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
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 = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, metrics) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.reconstruct_dd_events(&deals, &metrics);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"drawdown_events": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
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 = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.top_losses(&deals, limit);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"limit": limit,
|
||||
"top_losses": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
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 = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.loss_sequences(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"loss_sequences": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
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 = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.position_pairs(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"position_pairs": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
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 = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.direction_bias(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"direction_bias": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
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 = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.streak_analysis(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"streak_analysis": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
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 = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.concurrent_peak(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"concurrent_peak": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "concurrent_peak": analyzer.concurrent_peak(&deals) })))
|
||||
}
|
||||
|
||||
// === Deal Query Handlers ===
|
||||
pub async fn handle_analyze_profit_distribution(_config: &Config, args: &Value) -> Result<Value> {
|
||||
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 (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 (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 (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 (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 (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 (deals, metrics, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "efficiency_analysis": analyzer.efficiency_analysis(&deals, &metrics) })))
|
||||
}
|
||||
|
||||
// ── Deal query handlers ───────────────────────────────────────────────────────
|
||||
|
||||
fn deal_to_json(d: &Deal) -> Value {
|
||||
json!({
|
||||
"time": d.time,
|
||||
"deal": d.deal,
|
||||
"symbol": d.symbol,
|
||||
"deal_type": d.deal_type,
|
||||
"volume": d.volume,
|
||||
"price": d.price,
|
||||
"profit": d.profit,
|
||||
"commission": d.commission,
|
||||
"swap": d.swap,
|
||||
"comment": d.comment,
|
||||
"magic": d.magic,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_closed_trade(d: &Deal) -> bool {
|
||||
d.entry.to_lowercase().contains("out") && d.profit != 0.0
|
||||
}
|
||||
|
||||
pub async fn handle_list_deals(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
let (deals, _, _, _) = prepare_analysis(args)?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
|
||||
// Apply filters
|
||||
let deal_type = args.get("deal_type").and_then(|v| v.as_str());
|
||||
let deal_type = args.get("deal_type").and_then(|v| v.as_str());
|
||||
let min_profit = args.get("min_profit").and_then(|v| v.as_f64());
|
||||
let max_profit = args.get("max_profit").and_then(|v| v.as_f64());
|
||||
let start_date = args.get("start_date").and_then(|v| v.as_str());
|
||||
let end_date = args.get("end_date").and_then(|v| v.as_str());
|
||||
let end_date = args.get("end_date").and_then(|v| v.as_str());
|
||||
let min_volume = args.get("min_volume").and_then(|v| v.as_f64());
|
||||
let max_volume = args.get("max_volume").and_then(|v| v.as_f64());
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
|
||||
|
||||
let mut filtered: Vec<&Deal> = deals.iter().filter(|d| {
|
||||
// Only include closed trades with non-zero profit
|
||||
if !d.entry.to_lowercase().contains("out") || d.profit == 0.0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(dt) = deal_type {
|
||||
if !d.deal_type.to_lowercase().contains(dt) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(min) = min_profit {
|
||||
if d.profit < min {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(max) = max_profit {
|
||||
if d.profit > max {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(start) = start_date {
|
||||
if !d.time.starts_with(start) && d.time < start.to_string() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(end) = end_date {
|
||||
if d.time > end.to_string() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(min) = min_volume {
|
||||
if d.volume < min {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(max) = max_volume {
|
||||
if d.volume > max {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if !is_closed_trade(d) { return false; }
|
||||
if let Some(dt) = deal_type { if !d.deal_type.to_lowercase().contains(dt) { return false; } }
|
||||
if let Some(min) = min_profit { if d.profit < min { return false; } }
|
||||
if let Some(max) = max_profit { if d.profit > max { return false; } }
|
||||
if let Some(s) = start_date { if d.time.as_str() < s { return false; } }
|
||||
if let Some(e) = end_date { if d.time.as_str() > e { return false; } }
|
||||
if let Some(min) = min_volume { if d.volume < min { return false; } }
|
||||
if let Some(max) = max_volume { if d.volume > max { return false; } }
|
||||
true
|
||||
}).collect();
|
||||
|
||||
// Sort by time descending
|
||||
filtered.sort_by(|a, b| b.time.cmp(&a.time));
|
||||
filtered.truncate(limit);
|
||||
|
||||
let deal_list: Vec<Value> = filtered
|
||||
.iter()
|
||||
.map(|d| json!({
|
||||
"time": d.time,
|
||||
"deal": d.deal,
|
||||
"symbol": d.symbol,
|
||||
"deal_type": d.deal_type,
|
||||
"volume": d.volume,
|
||||
"price": d.price,
|
||||
"profit": d.profit,
|
||||
"commission": d.commission,
|
||||
"swap": d.swap,
|
||||
"comment": d.comment,
|
||||
"magic": d.magic,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"total_deals": deals.len(),
|
||||
"filtered_count": deal_list.len(),
|
||||
"deals": deal_list,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"total_deals": deals.len(),
|
||||
"filtered_count": filtered.len(),
|
||||
"deals": filtered.iter().map(|d| deal_to_json(d)).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn handle_search_deals_by_comment(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let query = args.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("query is required"))?;
|
||||
|
||||
let 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()
|
||||
.filter(|d| {
|
||||
d.entry.to_lowercase().contains("out")
|
||||
&& d.profit != 0.0
|
||||
&& d.comment.to_lowercase().contains(&query_lower)
|
||||
})
|
||||
|
||||
let mut filtered: Vec<&Deal> = deals.iter()
|
||||
.filter(|d| is_closed_trade(d) && d.comment.to_lowercase().contains(&query_lower))
|
||||
.collect();
|
||||
|
||||
filtered.sort_by(|a, b| b.time.cmp(&a.time));
|
||||
filtered.truncate(limit);
|
||||
|
||||
let deal_list: Vec<Value> = filtered
|
||||
.iter()
|
||||
.map(|d| json!({
|
||||
"time": d.time,
|
||||
"deal": d.deal,
|
||||
"symbol": d.symbol,
|
||||
"deal_type": d.deal_type,
|
||||
"volume": d.volume,
|
||||
"profit": d.profit,
|
||||
"comment": d.comment,
|
||||
"magic": d.magic,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"query": query,
|
||||
"matched": deal_list.len(),
|
||||
"deals": deal_list,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"query": query,
|
||||
"matched": filtered.len(),
|
||||
"deals": filtered.iter().map(|d| deal_to_json(d)).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn handle_search_deals_by_magic(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let magic = args.get("magic")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("magic is required"))?;
|
||||
|
||||
let magic = required_str(args, "magic")?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
|
||||
let (deals, _, _, _) = prepare_analysis(args)?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
|
||||
let mut filtered: Vec<&Deal> = deals
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
d.entry.to_lowercase().contains("out")
|
||||
&& d.profit != 0.0
|
||||
&& d.magic.as_ref().map(|m| m.contains(magic)).unwrap_or(false)
|
||||
})
|
||||
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))
|
||||
.collect();
|
||||
|
||||
filtered.sort_by(|a, b| b.time.cmp(&a.time));
|
||||
filtered.truncate(limit);
|
||||
|
||||
let deal_list: Vec<Value> = filtered
|
||||
.iter()
|
||||
.map(|d| json!({
|
||||
"time": d.time,
|
||||
"deal": d.deal,
|
||||
"symbol": d.symbol,
|
||||
"deal_type": d.deal_type,
|
||||
"volume": d.volume,
|
||||
"profit": d.profit,
|
||||
"comment": d.comment,
|
||||
"magic": d.magic,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"magic": magic,
|
||||
"matched": deal_list.len(),
|
||||
"deals": deal_list,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"magic": magic,
|
||||
"matched": filtered.len(),
|
||||
"deals": filtered.iter().map(|d| deal_to_json(d)).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
// === New Analytics Handlers ===
|
||||
|
||||
pub async fn handle_analyze_profit_distribution(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.profit_distribution(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"profit_distribution": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_time_performance(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.time_performance(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"time_performance": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_hold_time_distribution(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.hold_time_analysis(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"hold_time_analysis": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_layer_performance(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.layer_performance(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"layer_performance": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_volume_vs_profit(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.volume_analysis(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"volume_analysis": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_costs(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.cost_analysis(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"cost_analysis": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_efficiency(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, metrics) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.efficiency_analysis(&deals, &metrics);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"efficiency_analysis": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
// Import Config for analysis module
|
||||
use crate::models::Config;
|
||||
#[allow(dead_code)]
|
||||
fn _err_response_available() { let _ = err_response(""); }
|
||||
|
||||
+225
-12
@@ -2,7 +2,9 @@ use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use crate::models::Config;
|
||||
use crate::models::report::BacktestJob;
|
||||
use crate::pipeline::backtest::{BacktestParams, BacktestPipeline};
|
||||
|
||||
/// Pre-flight check result for backtest readiness
|
||||
@@ -145,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())
|
||||
}
|
||||
@@ -174,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());
|
||||
@@ -190,38 +193,248 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value>
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_run_backtest_quick(config: &Config, args: &Value) -> Result<Value> {
|
||||
// Quick backtest: skip compile, do clean → backtest → extract → analyze
|
||||
let mut args = args.clone();
|
||||
if let Some(obj) = args.as_object_mut() {
|
||||
obj.insert("skip_compile".to_string(), json!(true));
|
||||
// keep skip_analyze as false (default) to run analysis
|
||||
}
|
||||
handle_run_backtest(config, &args).await
|
||||
}
|
||||
|
||||
pub async fn handle_run_backtest_only(config: &Config, args: &Value) -> Result<Value> {
|
||||
// Backtest only: skip compile, skip analyze - just backtest and extract
|
||||
let mut args = args.clone();
|
||||
if let Some(obj) = args.as_object_mut() {
|
||||
obj.insert("skip_compile".to_string(), json!(true));
|
||||
obj.insert("skip_analyze".to_string(), json!(true));
|
||||
}
|
||||
handle_run_backtest(config, &args).await
|
||||
}
|
||||
|
||||
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(&handler.config, expert);
|
||||
|
||||
// Check account session
|
||||
if preflight.account.is_none() {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": "No active MT5 account session detected.",
|
||||
"hint": "Open MT5 and login to your trading account before running backtests."
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Get symbol
|
||||
let requested_symbol = args.get("symbol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let symbol = if requested_symbol.is_empty() {
|
||||
handler.config.backtest_symbol.clone()
|
||||
.or_else(|| preflight.available_symbols.first().cloned())
|
||||
.unwrap_or_else(|| "EURUSD".to_string())
|
||||
} else {
|
||||
requested_symbol.to_string()
|
||||
};
|
||||
|
||||
// EA existence check
|
||||
if !preflight.ea_exists {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": format!("EA '{}' not found in Experts directory.", expert),
|
||||
"hint": "Use search_experts or list_experts to find available EAs."
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Date defaulting
|
||||
let (from_date, to_date) = {
|
||||
let f = args.get("from_date").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let t = args.get("to_date").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if f.is_empty() || t.is_empty() {
|
||||
super::current_month()
|
||||
} else {
|
||||
(f.to_string(), t.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
let params = BacktestParams {
|
||||
expert: expert.to_string(),
|
||||
symbol: symbol.to_string(),
|
||||
from_date: from_date.to_string(),
|
||||
to_date: to_date.to_string(),
|
||||
timeframe: args.get("timeframe").and_then(|v| v.as_str()).unwrap_or("M5").to_string(),
|
||||
deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32,
|
||||
model: args.get("model").and_then(|v| v.as_u64()).unwrap_or(0) as u8,
|
||||
leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32,
|
||||
set_file: args.get("set_file").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
skip_compile: args.get("skip_compile").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
skip_clean: args.get("skip_clean").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
skip_analyze: true, // Not needed for launch mode
|
||||
deep_analyze: false,
|
||||
shutdown: false, // Don't shutdown so we can poll
|
||||
kill_existing: false,
|
||||
timeout: args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900),
|
||||
gui: args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
startup_delay_secs: args.get("startup_delay_secs").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
};
|
||||
|
||||
let pipeline = if let Some(ref callback) = handler.notification_callback {
|
||||
BacktestPipeline::with_notification_callback(handler.config.clone(), callback.clone())
|
||||
} else {
|
||||
BacktestPipeline::new(handler.config.clone())
|
||||
};
|
||||
let job = pipeline.launch_backtest(params).await?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"message": "Backtest launched successfully. Use get_backtest_status to poll for completion.",
|
||||
"report_id": job.report_id,
|
||||
"report_dir": job.report_dir,
|
||||
"expert": job.expert,
|
||||
"symbol": job.symbol,
|
||||
"timeframe": job.timeframe,
|
||||
"launched_at": job.launched_at,
|
||||
"timeout_seconds": job.timeout_seconds,
|
||||
"poll_hint": "Call get_backtest_status with report_dir to check progress"
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("latest");
|
||||
|
||||
let progress_file = Path::new(report_dir).join("progress.log");
|
||||
let report_path = Path::new(report_dir);
|
||||
let progress_file = report_path.join("progress.log");
|
||||
let job_file = report_path.join("job.json");
|
||||
|
||||
let status = if progress_file.exists() {
|
||||
// Load job info if available
|
||||
let job: Option<BacktestJob> = if job_file.exists() {
|
||||
fs::read_to_string(&job_file)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Check progress log for stage
|
||||
let (stage, progress_lines) = if progress_file.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&progress_file) {
|
||||
let last_line = content.lines().last().unwrap_or("");
|
||||
if last_line.contains("DONE") {
|
||||
"completed"
|
||||
} else {
|
||||
"running"
|
||||
}
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let last_stage = lines.last()
|
||||
.and_then(|l| l.split_whitespace().next())
|
||||
.unwrap_or("UNKNOWN");
|
||||
(last_stage.to_string(), lines.len())
|
||||
} else {
|
||||
"unknown"
|
||||
("UNKNOWN".to_string(), 0)
|
||||
}
|
||||
} else {
|
||||
("NOT_STARTED".to_string(), 0)
|
||||
};
|
||||
|
||||
// Check if MT5 is running
|
||||
let mt5_running = is_mt5_running();
|
||||
|
||||
// Check if report file exists
|
||||
let report_found = job.as_ref()
|
||||
.map(|j| Path::new(&j.expected_report_path).exists())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Check for completed artifacts
|
||||
let metrics_exists = report_path.join("metrics.json").exists();
|
||||
let deals_exists = report_path.join("deals.csv").exists();
|
||||
let is_complete = stage == "DONE" || (report_found && metrics_exists);
|
||||
|
||||
// Calculate elapsed time if job exists
|
||||
let elapsed_seconds = job.as_ref()
|
||||
.and_then(|j| {
|
||||
chrono::DateTime::parse_from_rfc3339(&j.launched_at)
|
||||
.ok()
|
||||
.map(|t| (chrono::Utc::now() - t.with_timezone(&chrono::Utc)).num_seconds())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
// Determine status message
|
||||
let status_msg = if is_complete {
|
||||
"completed"
|
||||
} else if stage == "BACKTEST" && mt5_running {
|
||||
"running"
|
||||
} else if stage == "BACKTEST" && !mt5_running && !report_found {
|
||||
"failed"
|
||||
} else if progress_lines > 0 {
|
||||
"in_progress"
|
||||
} else {
|
||||
"not_started"
|
||||
};
|
||||
|
||||
let message = if is_complete {
|
||||
"Backtest completed successfully"
|
||||
} else if stage == "BACKTEST" && mt5_running {
|
||||
"MT5 is running the backtest"
|
||||
} else if stage == "BACKTEST" && !mt5_running {
|
||||
"MT5 process exited but report not found - backtest may have failed"
|
||||
} else {
|
||||
&format!("Backtest is at stage: {}", stage)
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"report_dir": report_dir,
|
||||
"status": status
|
||||
"status": status_msg,
|
||||
"stage": stage,
|
||||
"is_complete": is_complete,
|
||||
"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| {
|
||||
json!({
|
||||
"report_id": j.report_id,
|
||||
"expert": j.expert,
|
||||
"symbol": j.symbol,
|
||||
"timeframe": j.timeframe,
|
||||
"launched_at": j.launched_at,
|
||||
"timeout_seconds": j.timeout_seconds
|
||||
})
|
||||
})
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check if MT5 is currently running
|
||||
fn is_mt5_running() -> bool {
|
||||
let patterns = if cfg!(target_os = "macos") {
|
||||
vec!["MetaTrader 5\\.app", "terminal64\\.exe"]
|
||||
} else {
|
||||
vec!["terminal64\\.exe", "metatrader"]
|
||||
};
|
||||
|
||||
patterns.iter().any(|pat| {
|
||||
Command::new("pgrep")
|
||||
.args(["-f", pat])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn handle_cache_status(config: &Config) -> Result<Value> {
|
||||
let cache_dir = config.tester_cache_dir.as_ref()
|
||||
.map(|s| Path::new(s))
|
||||
|
||||
+202
-458
@@ -4,212 +4,209 @@ use walkdir::WalkDir;
|
||||
use crate::compile::MqlCompiler;
|
||||
use crate::models::Config;
|
||||
|
||||
pub async fn handle_list_experts(config: &Config, args: &Value) -> Result<Value> {
|
||||
let filter = args.get("filter").and_then(|v| v.as_str());
|
||||
|
||||
let mut experts = Vec::new();
|
||||
|
||||
if let Some(experts_dir) = &config.experts_dir {
|
||||
for entry in WalkDir::new(experts_dir).max_depth(3) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
let is_compiled = path.extension()
|
||||
.map(|e| e == "ex5")
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(filter_str) = filter {
|
||||
if !name_str.to_lowercase().contains(&filter_str.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
experts.push(json!({
|
||||
"name": name_str,
|
||||
"compiled": is_compiled,
|
||||
"path": path.to_string_lossy().to_string(),
|
||||
}));
|
||||
const BUILTIN_INDICATORS: &[&str] = &[
|
||||
"Accelerator", "Accumulation", "ADX", "Alligator", "AO", "ATR",
|
||||
"Bands", "Bears", "Bulls", "CCI", "DeMarker", "Envelopes", "Force",
|
||||
"Fractals", "Gator", "Ichimoku", "MA", "MACD", "MFI", "Momentum",
|
||||
"OBV", "OsMA", "RSI", "RVI", "SAR", "StdDev", "Stochastic", "WPR",
|
||||
];
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// Walk a MQL directory and return file entries matching an optional filter.
|
||||
/// `type_label` is included as a `"type"` field when provided (e.g. "custom").
|
||||
fn scan_mql_dir(dir: Option<&String>, filter: Option<&str>, type_label: Option<&str>) -> Vec<Value> {
|
||||
let Some(dir) = dir else { return Vec::new() };
|
||||
let filter_lower = filter.map(|f| f.to_lowercase());
|
||||
|
||||
let mut items: Vec<Value> = WalkDir::new(dir)
|
||||
.max_depth(3)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_file())
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
let name = path.file_stem()?.to_string_lossy().into_owned();
|
||||
if let Some(ref f) = filter_lower {
|
||||
if !name.to_lowercase().contains(f.as_str()) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
experts.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"count": experts.len(),
|
||||
"experts": experts,
|
||||
}).to_string() }],
|
||||
let is_compiled = path.extension().map(|ext| ext == "ex5").unwrap_or(false);
|
||||
let mut obj = json!({
|
||||
"name": name,
|
||||
"compiled": is_compiled,
|
||||
"path": path.to_string_lossy().as_ref(),
|
||||
});
|
||||
if let Some(t) = type_label {
|
||||
obj["type"] = json!(t);
|
||||
}
|
||||
Some(obj)
|
||||
})
|
||||
.collect();
|
||||
|
||||
items.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
items
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
/// Shared implementation for copy_indicator_to_project / copy_script_to_project.
|
||||
/// `default_fallback` is used as the stem when the source has no filename.
|
||||
fn copy_mql_to_project(config: &Config, args: &Value, default_fallback: &str) -> Result<Value> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let source_path = args.get("source_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("source_path is required"))?;
|
||||
|
||||
let target_name = args.get("target_name").and_then(|v| v.as_str());
|
||||
|
||||
let project_dir = config.project_dir.as_ref()
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.ok_or_else(|| anyhow::anyhow!("Cannot determine project directory"))?;
|
||||
|
||||
let source = PathBuf::from(source_path);
|
||||
if !source.exists() {
|
||||
return Ok(err_response(
|
||||
serde_json::to_string(&json!({ "success": false, "error": format!("Source file not found: {}", source_path) })).unwrap_or_default()
|
||||
));
|
||||
}
|
||||
|
||||
let ext = source.extension().and_then(|e| e.to_str()).unwrap_or("mq5");
|
||||
let target_filename = match target_name {
|
||||
Some(name) => format!("{}.{}", name, ext),
|
||||
None => source.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| format!("{}.{}", default_fallback, ext)),
|
||||
};
|
||||
|
||||
let destination = project_dir.join(&target_filename);
|
||||
|
||||
match std::fs::copy(&source, &destination) {
|
||||
Ok(bytes) => Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"source": source_path,
|
||||
"destination": destination.to_string_lossy().as_ref(),
|
||||
"bytes_copied": bytes,
|
||||
}))),
|
||||
Err(e) => Ok(err_response(
|
||||
serde_json::to_string(&json!({ "success": false, "error": format!("Failed to copy file: {}", e) })).unwrap_or_default()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public handlers ───────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn handle_list_experts(config: &Config, args: &Value) -> Result<Value> {
|
||||
let filter = args.get("filter").and_then(|v| v.as_str());
|
||||
let experts = scan_mql_dir(config.experts_dir.as_ref(), filter, None);
|
||||
Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"count": experts.len(),
|
||||
"experts": experts,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn handle_search_experts(config: &Config, args: &Value) -> Result<Value> {
|
||||
let pattern = args.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
|
||||
|
||||
let pattern_lower = pattern.to_lowercase();
|
||||
let mut matches = Vec::new();
|
||||
|
||||
if let Some(experts_dir) = &config.experts_dir {
|
||||
for entry in WalkDir::new(experts_dir).max_depth(3) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
if name_str.to_lowercase().contains(&pattern_lower) {
|
||||
let is_compiled = path.extension()
|
||||
.map(|e| e == "ex5")
|
||||
.unwrap_or(false);
|
||||
matches.push(json!({
|
||||
"name": name_str,
|
||||
"path": path.to_string_lossy().to_string(),
|
||||
"compiled": is_compiled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"pattern": pattern,
|
||||
"count": matches.len(),
|
||||
"matches": matches,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
let matches = scan_mql_dir(config.experts_dir.as_ref(), Some(pattern), None);
|
||||
Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"pattern": pattern,
|
||||
"count": matches.len(),
|
||||
"matches": matches,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn handle_list_indicators(config: &Config, args: &Value) -> Result<Value> {
|
||||
let filter = args.get("filter").and_then(|v| v.as_str());
|
||||
let include_builtin = args.get("include_builtin").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let mut indicators = Vec::new();
|
||||
|
||||
// List custom indicators
|
||||
if let Some(indicators_dir) = &config.indicators_dir {
|
||||
for entry in WalkDir::new(indicators_dir).max_depth(3) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
let is_compiled = path.extension()
|
||||
.map(|e| e == "ex5")
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(filter_str) = filter {
|
||||
if !name_str.to_lowercase().contains(&filter_str.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
indicators.push(json!({
|
||||
"name": name_str,
|
||||
"compiled": is_compiled,
|
||||
"type": "custom",
|
||||
"path": path.to_string_lossy().to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add built-in indicators if requested
|
||||
|
||||
let mut indicators = scan_mql_dir(config.indicators_dir.as_ref(), filter, Some("custom"));
|
||||
|
||||
if include_builtin {
|
||||
let builtin = vec![
|
||||
"Accelerator", "Accumulation", "ADX", "Alligator", "AO", "ATR",
|
||||
"Bands", "Bears", "Bulls", "CCI", "DeMarker", "Envelopes", "Force",
|
||||
"Fractals", "Gator", "Ichimoku", "MA", "MACD", "MFI", "Momentum",
|
||||
"OBV", "OsMA", "RSI", "RVI", "SAR", "StdDev", "Stochastic", "WPR",
|
||||
];
|
||||
for name in builtin {
|
||||
if filter.map(|f| name.to_lowercase().contains(&f.to_lowercase())).unwrap_or(true) {
|
||||
indicators.push(json!({
|
||||
"name": name,
|
||||
"compiled": true,
|
||||
"type": "builtin",
|
||||
"path": null,
|
||||
}));
|
||||
let filter_lower = filter.map(|f| f.to_lowercase());
|
||||
for &name in BUILTIN_INDICATORS {
|
||||
if filter_lower.as_ref().map(|f| name.to_lowercase().contains(f.as_str())).unwrap_or(true) {
|
||||
indicators.push(json!({ "name": name, "compiled": true, "type": "builtin", "path": null }));
|
||||
}
|
||||
}
|
||||
indicators.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
}
|
||||
|
||||
indicators.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"count": indicators.len(),
|
||||
"indicators": indicators,
|
||||
"custom_dir": config.indicators_dir.clone(),
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
|
||||
Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"count": indicators.len(),
|
||||
"indicators": indicators,
|
||||
"custom_dir": config.indicators_dir.clone(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn handle_list_scripts(config: &Config, args: &Value) -> Result<Value> {
|
||||
let filter = args.get("filter").and_then(|v| v.as_str());
|
||||
|
||||
let mut scripts = Vec::new();
|
||||
|
||||
if let Some(scripts_dir) = &config.scripts_dir {
|
||||
for entry in WalkDir::new(scripts_dir).max_depth(3) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
let is_compiled = path.extension()
|
||||
.map(|e| e == "ex5")
|
||||
.unwrap_or(false);
|
||||
|
||||
if let Some(filter_str) = filter {
|
||||
if !name_str.to_lowercase().contains(&filter_str.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
scripts.push(json!({
|
||||
"name": name_str,
|
||||
"compiled": is_compiled,
|
||||
"path": path.to_string_lossy().to_string(),
|
||||
}));
|
||||
}
|
||||
let scripts = scan_mql_dir(config.scripts_dir.as_ref(), filter, None);
|
||||
Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"count": scripts.len(),
|
||||
"scripts": scripts,
|
||||
"scripts_dir": config.scripts_dir.clone(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn handle_search_indicators(config: &Config, args: &Value) -> Result<Value> {
|
||||
let pattern = args.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
|
||||
let include_builtin = args.get("include_builtin").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let mut matches = scan_mql_dir(config.indicators_dir.as_ref(), Some(pattern), Some("custom"));
|
||||
|
||||
if include_builtin {
|
||||
let pattern_lower = pattern.to_lowercase();
|
||||
for &name in BUILTIN_INDICATORS {
|
||||
if name.to_lowercase().contains(&pattern_lower) {
|
||||
matches.push(json!({ "name": name, "path": null, "type": "builtin", "compiled": true }));
|
||||
}
|
||||
}
|
||||
matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
}
|
||||
|
||||
scripts.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"count": scripts.len(),
|
||||
"scripts": scripts,
|
||||
"scripts_dir": config.scripts_dir.clone(),
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
|
||||
Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"pattern": pattern,
|
||||
"count": matches.len(),
|
||||
"matches": matches,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn handle_search_scripts(config: &Config, args: &Value) -> Result<Value> {
|
||||
let pattern = args.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
|
||||
let matches = scan_mql_dir(config.scripts_dir.as_ref(), Some(pattern), None);
|
||||
Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"pattern": pattern,
|
||||
"count": matches.len(),
|
||||
"matches": matches,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn handle_compile_ea(config: &Config, args: &Value) -> Result<Value> {
|
||||
@@ -218,302 +215,49 @@ pub async fn handle_compile_ea(config: &Config, args: &Value) -> Result<Value> {
|
||||
let resolved_path: String = if let Some(p) = args.get("expert_path").and_then(|v| v.as_str()) {
|
||||
p.to_string()
|
||||
} else if let Some(name) = args.get("expert").and_then(|v| v.as_str()) {
|
||||
let mut candidates = vec![
|
||||
PathBuf::from(name).with_extension("mq5"),
|
||||
];
|
||||
let mut candidates = vec![PathBuf::from(name).with_extension("mq5")];
|
||||
if let Some(experts_dir) = &config.experts_dir {
|
||||
candidates.push(PathBuf::from(experts_dir).join(name).join(format!("{}.mq5", name)));
|
||||
candidates.push(PathBuf::from(experts_dir).join(format!("{}.mq5", name)));
|
||||
}
|
||||
match candidates.into_iter().find(|p| p.exists()) {
|
||||
Some(p) => p.to_string_lossy().to_string(),
|
||||
None => return Ok(serde_json::json!({
|
||||
"content": [{ "type": "text", "text": serde_json::json!({
|
||||
None => return Ok(err_response(
|
||||
serde_json::to_string(&json!({
|
||||
"success": false,
|
||||
"error": format!("Cannot find {}.mq5 in MT5 Experts dir or current directory", name),
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
})),
|
||||
})).unwrap_or_default()
|
||||
)),
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("Either 'expert' or 'expert_path' is required"));
|
||||
};
|
||||
|
||||
let compiler = MqlCompiler::new(config.clone());
|
||||
let expert_path = resolved_path.as_str();
|
||||
|
||||
match compiler.compile(&expert_path) {
|
||||
Ok(result) => {
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": result.success,
|
||||
"binary_path": result.ex5_path.map(|p| p.to_string_lossy().to_string()),
|
||||
"binary_size_bytes": result.binary_size,
|
||||
"files_synced": result.files_synced,
|
||||
"warnings": result.warnings.len(),
|
||||
"errors": result.errors.len(),
|
||||
"error_list": result.errors,
|
||||
"warning_list": result.warnings,
|
||||
}).to_string() }],
|
||||
"isError": !result.success
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": false,
|
||||
"error": format!("Compilation failed: {}", e),
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
match compiler.compile(&resolved_path).await {
|
||||
Ok(result) => Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": result.success,
|
||||
"binary_path": result.ex5_path.map(|p| p.to_string_lossy().to_string()),
|
||||
"binary_size_bytes": result.binary_size,
|
||||
"files_synced": result.files_synced,
|
||||
"warnings": result.warnings.len(),
|
||||
"errors": result.errors.len(),
|
||||
"error_list": result.errors,
|
||||
"warning_list": result.warnings,
|
||||
}).to_string() }],
|
||||
"isError": !result.success
|
||||
})),
|
||||
Err(e) => Ok(err_response(
|
||||
serde_json::to_string(&json!({ "success": false, "error": format!("Compilation failed: {}", e) })).unwrap_or_default()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_search_indicators(config: &Config, args: &Value) -> Result<Value> {
|
||||
let pattern = args.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
|
||||
|
||||
let include_builtin = args.get("include_builtin").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let pattern_lower = pattern.to_lowercase();
|
||||
|
||||
let mut matches = Vec::new();
|
||||
|
||||
// Search custom indicators recursively
|
||||
if let Some(indicators_dir) = &config.indicators_dir {
|
||||
for entry in WalkDir::new(indicators_dir).max_depth(3) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
if name_str.to_lowercase().contains(&pattern_lower) {
|
||||
let is_compiled = path.extension()
|
||||
.map(|e| e == "ex5")
|
||||
.unwrap_or(false);
|
||||
matches.push(json!({
|
||||
"name": name_str,
|
||||
"path": path.to_string_lossy().to_string(),
|
||||
"type": "custom",
|
||||
"compiled": is_compiled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search built-in indicators if requested
|
||||
if include_builtin {
|
||||
let builtin = vec![
|
||||
"Accelerator", "Accumulation", "ADX", "Alligator", "AO", "ATR",
|
||||
"Bands", "Bears", "Bulls", "CCI", "DeMarker", "Envelopes", "Force",
|
||||
"Fractals", "Gator", "Ichimoku", "MA", "MACD", "MFI", "Momentum",
|
||||
"OBV", "OsMA", "RSI", "RVI", "SAR", "StdDev", "Stochastic", "WPR",
|
||||
];
|
||||
for name in builtin {
|
||||
if name.to_lowercase().contains(&pattern_lower) {
|
||||
matches.push(json!({
|
||||
"name": name,
|
||||
"path": null,
|
||||
"type": "builtin",
|
||||
"compiled": true,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"pattern": pattern,
|
||||
"count": matches.len(),
|
||||
"matches": matches,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_search_scripts(config: &Config, args: &Value) -> Result<Value> {
|
||||
let pattern = args.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
|
||||
|
||||
let pattern_lower = pattern.to_lowercase();
|
||||
let mut matches = Vec::new();
|
||||
|
||||
if let Some(scripts_dir) = &config.scripts_dir {
|
||||
for entry in WalkDir::new(scripts_dir).max_depth(3) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = path.file_stem() {
|
||||
let name_str = name.to_string_lossy().to_string();
|
||||
if name_str.to_lowercase().contains(&pattern_lower) {
|
||||
let is_compiled = path.extension()
|
||||
.map(|e| e == "ex5")
|
||||
.unwrap_or(false);
|
||||
matches.push(json!({
|
||||
"name": name_str,
|
||||
"path": path.to_string_lossy().to_string(),
|
||||
"compiled": is_compiled,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"pattern": pattern,
|
||||
"count": matches.len(),
|
||||
"matches": matches,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_copy_indicator_to_project(config: &Config, args: &Value) -> Result<Value> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let source_path = args.get("source_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("source_path is required"))?;
|
||||
|
||||
let target_name = args.get("target_name").and_then(|v| v.as_str());
|
||||
|
||||
// Determine project directory
|
||||
let project_dir = config.project_dir.as_ref()
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.ok_or_else(|| anyhow::anyhow!("Cannot determine project directory"))?;
|
||||
|
||||
let source = PathBuf::from(source_path);
|
||||
if !source.exists() {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": false,
|
||||
"error": format!("Source file not found: {}", source_path),
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Get extension
|
||||
let ext = source.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("mq5");
|
||||
|
||||
// Determine target name
|
||||
let target_filename = match target_name {
|
||||
Some(name) => format!("{}.{}", name, ext),
|
||||
None => source.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| format!("indicator.{}", ext)),
|
||||
};
|
||||
|
||||
let destination = project_dir.join(&target_filename);
|
||||
|
||||
// Copy file
|
||||
match std::fs::copy(&source, &destination) {
|
||||
Ok(bytes) => {
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"source": source_path,
|
||||
"destination": destination.to_string_lossy().to_string(),
|
||||
"bytes_copied": bytes,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": false,
|
||||
"error": format!("Failed to copy file: {}", e),
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
}
|
||||
copy_mql_to_project(config, args, "indicator")
|
||||
}
|
||||
|
||||
pub async fn handle_copy_script_to_project(config: &Config, args: &Value) -> Result<Value> {
|
||||
use std::path::PathBuf;
|
||||
|
||||
let source_path = args.get("source_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("source_path is required"))?;
|
||||
|
||||
let target_name = args.get("target_name").and_then(|v| v.as_str());
|
||||
|
||||
// Determine project directory
|
||||
let project_dir = config.project_dir.as_ref()
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::current_dir().ok())
|
||||
.ok_or_else(|| anyhow::anyhow!("Cannot determine project directory"))?;
|
||||
|
||||
let source = PathBuf::from(source_path);
|
||||
if !source.exists() {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": false,
|
||||
"error": format!("Source file not found: {}", source_path),
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Get extension
|
||||
let ext = source.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("mq5");
|
||||
|
||||
// Determine target name
|
||||
let target_filename = match target_name {
|
||||
Some(name) => format!("{}.{}", name, ext),
|
||||
None => source.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| format!("script.{}", ext)),
|
||||
};
|
||||
|
||||
let destination = project_dir.join(&target_filename);
|
||||
|
||||
// Copy file
|
||||
match std::fs::copy(&source, &destination) {
|
||||
Ok(bytes) => {
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"source": source_path,
|
||||
"destination": destination.to_string_lossy().to_string(),
|
||||
"bytes_copied": bytes,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": false,
|
||||
"error": format!("Failed to copy file: {}", e),
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
}
|
||||
copy_mql_to_project(config, args, "script")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -42,8 +68,11 @@ impl ToolHandler {
|
||||
"copy_indicator_to_project" => experts::handle_copy_indicator_to_project(&self.config, args).await,
|
||||
"copy_script_to_project" => experts::handle_copy_script_to_project(&self.config, args).await,
|
||||
|
||||
// Backtest handlers
|
||||
"run_backtest" => backtest::handle_run_backtest(&self.config, args).await,
|
||||
// Backtest handlers - Granular pipeline options
|
||||
"run_backtest" => backtest::handle_run_backtest(&self.config, args).await, // Full: compile + clean + backtest + extract + analyze
|
||||
"run_backtest_quick" => backtest::handle_run_backtest_quick(&self.config, args).await, // Quick: skip compile, do backtest + extract + analyze
|
||||
"run_backtest_only" => backtest::handle_run_backtest_only(&self.config, args).await, // Minimal: skip compile, do backtest + extract only
|
||||
"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,
|
||||
@@ -106,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,
|
||||
@@ -162,3 +192,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;
|
||||
|
||||
@@ -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