Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 91af1bb6dd | |||
| bb6240657e | |||
| 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 | |||
| a1434914e9 | |||
| 6ce8808948 | |||
| 896aa6111e | |||
| aab34a1fde | |||
| 96c7e0a9c2 | |||
| 0c82501e18 | |||
| 2fef0db3c4 | |||
| 95ceef3e3f | |||
| 662e9e324c | |||
| 6d1f940887 | |||
| 41c8b36826 | |||
| 42318b040a | |||
| fdb45b8eed | |||
| b63bda64ab | |||
| cbed3d15af | |||
| 8cce92c409 |
@@ -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 "─────────────────────────────────────────────────────────────"
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
+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.28.0"
|
||||
version = "1.31.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mt5-quant"
|
||||
version = "1.28.0"
|
||||
version = "1.31.5"
|
||||
edition = "2021"
|
||||
description = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP"
|
||||
authors = ["masdevid <masdevid@example.com>"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MT5-Quant
|
||||
|
||||
**MCP server for MT5 strategy development on macOS/Linux.** 43 tools to compile, backtest, analyze, and optimize MQL5 Expert Advisors — no Windows required.
|
||||
**MCP server for MT5 strategy development on macOS/Linux.** 87 tools to compile, backtest, analyze, optimize, debug crashes, and manage MQL5 Expert Advisors — no Windows required.
|
||||
|
||||
```
|
||||
You: "Backtest MyEA Jan-Mar, what caused the February drawdown?"
|
||||
@@ -20,17 +20,29 @@ Claude: [compile → clean → backtest → analyze 1,847 deals]
|
||||
| MQL5 compilation | ✅ | ❌ | ❌ |
|
||||
| macOS/Linux native | ✅ | Windows only | Cloud |
|
||||
| Optimization | ✅ Background | ❌ | ✅ Paid |
|
||||
| Crash debugging | ✅ Wine/MT5 diagnostics | ❌ | ❌ |
|
||||
|
||||
## Quick Install
|
||||
|
||||
### 1. Download & Setup
|
||||
|
||||
```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/mt5-quant-macos-arm64.tar.gz
|
||||
tar -xzf mt5.tar.gz
|
||||
bash scripts/setup.sh
|
||||
claude mcp add MT5-Quant -- $(pwd)/mt5-quant
|
||||
```
|
||||
|
||||
**[Full Setup →](docs/QUICKSTART.md)**
|
||||
### 2. Register MCP Server
|
||||
|
||||
| Platform | Command / Config | Docs |
|
||||
|----------|------------------|------|
|
||||
| **Claude Code** | `claude mcp add mt5-quant -- $(pwd)/mt5-quant` | [Setup →](docs/QUICKSTART.md) |
|
||||
| **Windsurf** | Edit `~/.codeium/windsurf/mcp_config.json` | [WINDSURF.md →](docs/WINDSURF.md) |
|
||||
| **Cursor** | Edit `~/.cursor/mcp.json` or use Settings → MCP | [CURSOR.md →](docs/CURSOR.md) |
|
||||
| **VS Code** | Edit `.vscode/mcp.json` or run `MCP: Add Server` | [VSCODE.md →](docs/VSCODE.md) |
|
||||
| **Claude Desktop** | Agent Panel → ... → MCP Servers → Edit configuration | [CLAUDE.md →](docs/CLAUDE.md) |
|
||||
|
||||
> **Note:** Use absolute paths like `/Users/name/mt5-quant/mt5-quant` or `$(pwd)/mt5-quant`, not relative paths like `./mt5-quant`.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -45,20 +57,27 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
|
||||
| Doc | Purpose |
|
||||
|-----|---------|
|
||||
| [QUICKSTART.md](docs/QUICKSTART.md) | Complete setup for macOS/Linux |
|
||||
| [WINDSURF.md](docs/WINDSURF.md) | Windsurf IDE setup |
|
||||
| [CURSOR.md](docs/CURSOR.md) | Cursor IDE setup |
|
||||
| [VSCODE.md](docs/VSCODE.md) | VS Code setup |
|
||||
| [CLAUDE.md](docs/CLAUDE.md) | Claude Desktop setup |
|
||||
| [CONFIG.md](docs/CONFIG.md) | Configuration reference |
|
||||
| [WINDSURF.md](docs/WINDSURF.md) | Windsurf IDE integration |
|
||||
| [TOOLS.md](docs/MCP_TOOLS.md) | All 43 tools (31 documented) |
|
||||
| [TOOLS.md](docs/MCP_TOOLS.md) | All 87 tools documented |
|
||||
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design and internals |
|
||||
| [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Common issues |
|
||||
| [REMOTE_AGENTS.md](docs/REMOTE_AGENTS.md) | Linux optimization agents |
|
||||
|
||||
## MCP Tools (43)
|
||||
## 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 |
|
||||
| `analyze_report` | Read `analysis.json` from any report directory |
|
||||
@@ -84,12 +103,26 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
|
||||
|
||||
Use these for targeted analysis, or `analyze_report` to run all at once.
|
||||
|
||||
### Deal-Level Analytics (New)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_deals` | List individual deals with filters (type, profit range, volume, dates) |
|
||||
| `search_deals_by_comment` | Full-text search in deal comments (e.g., "Layer #3") |
|
||||
| `search_deals_by_magic` | Filter deals by EA magic number |
|
||||
| `analyze_profit_distribution` | Profit histogram: small/medium/large wins and losses |
|
||||
| `analyze_time_performance` | Performance by hour of day and day of week |
|
||||
| `analyze_hold_time_distribution` | Hold time buckets + correlation with profit |
|
||||
| `analyze_layer_performance` | Grid/martingale layer analysis from comments |
|
||||
| `analyze_volume_vs_profit` | Volume correlation + performance by lot size |
|
||||
| `analyze_costs` | Commission and swap impact on profitability |
|
||||
| `analyze_efficiency` | Profit per hour/day, annualized return, trade frequency |
|
||||
|
||||
### Monitoring
|
||||
|
||||
| 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 |
|
||||
|
||||
@@ -100,6 +133,14 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
|
||||
| `list_reports` | Compact table of all runs with key metrics — no full analysis needed |
|
||||
| `get_latest_report` | Get most recent report with optional equity chart |
|
||||
| `search_reports` | Find reports by EA, symbol, date range, or profit criteria |
|
||||
| `get_report_by_id` | Get specific report by ID with equity chart |
|
||||
| `get_reports_summary` | Aggregate stats: counts, averages, pass rates |
|
||||
| `get_best_reports` | Top N reports sorted by any metric (profit factor, drawdown, etc.) |
|
||||
| `search_reports_by_tags` | Find reports by tags |
|
||||
| `search_reports_by_date_range` | Query by backtest date range |
|
||||
| `search_reports_by_notes` | Full-text search in report notes |
|
||||
| `get_reports_by_set_file` | Find all reports using a specific .set file |
|
||||
| `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) |
|
||||
|
||||
@@ -120,6 +161,44 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
|
||||
| `cache_status` | MT5 tester cache size breakdown by symbol — check before cleaning |
|
||||
| `clean_cache` | Delete tester cache files; supports per-symbol and `dry_run` |
|
||||
|
||||
### Pre-flight & Validation
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_active_account` | Get current MT5 account session (login, server, available symbols) |
|
||||
| `check_symbol_data_status` | Validate symbol has sufficient history data for date range |
|
||||
| `check_mt5_status` | Check if MT5 terminal is installed and ready |
|
||||
| `validate_ea_syntax` | Pre-compile syntax check without running full compilation |
|
||||
|
||||
### Debugging & Diagnostics (New)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `diagnose_wine` | Check Wine installation, version, and prefix health |
|
||||
| `get_mt5_logs` | Get MT5 terminal, tester, or MetaEditor logs with filtering |
|
||||
| `search_mt5_errors` | Search logs for error patterns (crash, exception, access violation) |
|
||||
| `check_mt5_process` | Check if MT5 processes are running, get PID, CPU, memory usage |
|
||||
| `kill_mt5_process` | Kill stuck MT5 processes (force=true for wineserver) |
|
||||
| `check_system_resources` | Check disk space, memory, CPU availability |
|
||||
| `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 |
|
||||
|
||||
### Project Management
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `init_project` | Scaffold new MQL5 project with templates (scalper/swing/grid/basic) |
|
||||
| `create_set_template` | Generate .set parameter file from EA input variables |
|
||||
| `export_report` | Export backtest report to CSV, JSON, or Markdown |
|
||||
|
||||
### History & Comparison
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `get_backtest_history` | List all backtests for EA/symbol with summary metrics |
|
||||
| `compare_backtests` | Compare 2+ backtest results side-by-side with analysis |
|
||||
|
||||
### .set file — read / write
|
||||
|
||||
| Tool | Description |
|
||||
@@ -138,12 +217,28 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
|
||||
| `diff_set_files` | Side-by-side diff of two `.set` files — only changed params returned |
|
||||
| `set_from_optimization` | Generate a clean backtest `.set` from `get_optimization_results` params; optionally narrow sweep |
|
||||
|
||||
### Search & Discovery
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `search_experts` | Search EAs by name pattern across all directories |
|
||||
| `search_indicators` | Search indicators by name pattern |
|
||||
| `search_scripts` | Search scripts by name pattern |
|
||||
| `copy_indicator_to_project` | Copy indicator to project directory |
|
||||
| `copy_script_to_project` | Copy script to project directory |
|
||||
|
||||
Full schema: [docs/MCP_TOOLS.md](docs/MCP_TOOLS.md)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run `verify_setup` from Claude first — it checks all paths and returns actionable hints.
|
||||
|
||||
For crashes or unexplained failures during backtest/compile/optimization:
|
||||
- `diagnose_wine` — Check Wine installation and prefix health
|
||||
- `search_mt5_errors` — Find crash causes in logs
|
||||
- `check_mt5_process` + `kill_mt5_process` — Detect and kill stuck processes
|
||||
- `get_backtest_crash_info` — Investigate failed backtest reports
|
||||
|
||||
**[Full Troubleshooting Guide →](docs/TROUBLESHOOTING.md)**
|
||||
|
||||
---
|
||||
@@ -153,5 +248,3 @@ Run `verify_setup` from Claude first — it checks all paths and returns actiona
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
*Built from battle-tested production infrastructure. Every edge case in the pipeline was hit in production.*
|
||||
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# 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/target/release/mt5-quant"
|
||||
},
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${env:GITHUB_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Claude Desktop supports `${env:VAR_NAME}` syntax for environment variable substitution:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"command": "/Users/name/mt5-quant/target/release/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)
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Cursor 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-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 2: Build from Source
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Configure Cursor
|
||||
|
||||
### Method 1: Settings UI (Recommended)
|
||||
|
||||
1. Open Cursor Settings (`Cmd/Ctrl + ,`)
|
||||
2. Navigate to **Features** → **MCP**
|
||||
3. Click **Add Custom MCP**
|
||||
4. Enter:
|
||||
- **Name**: `mt5-quant`
|
||||
- **Command**: `~/.local/bin/mt5-quant`
|
||||
- **Type**: `stdio`
|
||||
|
||||
### Method 2: Edit mcp.json Directly
|
||||
|
||||
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"type": "stdio",
|
||||
"command": "~/.local/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create the file if it doesn't exist:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.cursor
|
||||
cat > ~/.cursor/mcp.json << 'EOF'
|
||||
{
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"type": "stdio",
|
||||
"command": "/path/to/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
## Verify Setup
|
||||
|
||||
In Cursor chat, type:
|
||||
|
||||
```
|
||||
Run verify_setup
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Wine: /Applications/MetaTrader 5.app/.../wine64
|
||||
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
|
||||
Display: gui
|
||||
Arch: arch -x86_64
|
||||
```
|
||||
|
||||
## Configuration Locations
|
||||
|
||||
| Scope | Path | Use Case |
|
||||
|-------|------|----------|
|
||||
| Global | `~/.cursor/mcp.json` | Available in all projects |
|
||||
| Project | `.cursor/mcp.json` | Project-specific tools |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### MCP server not appearing
|
||||
|
||||
1. Check MCP panel in Cursor Settings
|
||||
2. Verify the path is absolute (not relative)
|
||||
3. Test binary: `/path/to/mt5-quant --help`
|
||||
4. View MCP logs: Output panel → select "MCP" from dropdown
|
||||
|
||||
### Config interpolation
|
||||
|
||||
Cursor supports variable substitution in `mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"command": "${userHome}/bin/mt5-quant",
|
||||
"args": ["--config", "${workspaceFolder}/config.yaml"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Available variables:
|
||||
- `${userHome}` - Home directory
|
||||
- `${workspaceFolder}` - Project root
|
||||
- `${workspaceFolderBasename}` - Project folder name
|
||||
- `${env:VAR_NAME}` - Environment variable
|
||||
|
||||
### Tool not found errors
|
||||
|
||||
If the agent says "Tool not found":
|
||||
1. Check the server is enabled in MCP settings
|
||||
2. Try disabling and re-enabling the server
|
||||
3. Restart Cursor
|
||||
|
||||
## Resources
|
||||
|
||||
- [Cursor MCP Documentation](https://cursor.com/docs/context/mcp)
|
||||
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
|
||||
+467
-3
@@ -2,11 +2,15 @@
|
||||
|
||||
Full input/output schemas for MT5-Quant tools.
|
||||
|
||||
> **Documentation Status:** This file documents 31 of 43 total tools. Missing:
|
||||
> **Documentation Status:** This file documents 56 of 89 total tools. Missing:
|
||||
> - `list_experts`, `list_indicators`, `list_scripts`
|
||||
> - `healthcheck`
|
||||
> - `search_reports`, `get_latest_report`
|
||||
> - `healthcheck`, `list_symbols`
|
||||
> - Reports query: `search_reports`, `get_latest_report`, `list_reports`, `prune_reports`, `tail_log`, `get_report_by_id`, `get_reports_summary`, `get_best_reports`, `search_reports_by_tags`, `search_reports_by_date_range`, `search_reports_by_notes`, `get_reports_by_set_file`, `get_comparable_reports`
|
||||
> - Granular analytics: `analyze_monthly_pnl`, `analyze_drawdown_events`, `analyze_top_losses`, `analyze_loss_sequences`, `analyze_position_pairs`, `analyze_direction_bias`, `analyze_streaks`, `analyze_concurrent_peak`
|
||||
> - Deal analytics: `list_deals`, `search_deals_by_comment`, `search_deals_by_magic`, `analyze_profit_distribution`, `analyze_time_performance`, `analyze_hold_time_distribution`, `analyze_layer_performance`, `analyze_volume_vs_profit`, `analyze_costs`, `analyze_efficiency`
|
||||
> - Archive/history tools: `archive_report`, `archive_all_reports`, `get_history`, `annotate_history`, `promote_to_baseline`
|
||||
> - Experts search: `search_experts`, `search_indicators`, `search_scripts`, `copy_indicator_to_project`, `copy_script_to_project`
|
||||
> - Debugging/diagnostics: `diagnose_wine`, `get_mt5_logs`, `search_mt5_errors`, `check_mt5_process`, `kill_mt5_process`, `check_system_resources`, `validate_mt5_config`, `get_wine_prefix_info`, `get_backtest_crash_info`
|
||||
|
||||
---
|
||||
|
||||
@@ -141,6 +145,124 @@ Run a complete backtest pipeline: compile → clean cache → backtest → extra
|
||||
|
||||
---
|
||||
|
||||
## `run_backtest_quick`
|
||||
|
||||
Quick backtest using pre-compiled EA: clean cache → backtest → extract → analyze.
|
||||
|
||||
**When to call:** When EA code hasn't changed and you just want to test different parameters or date ranges. Faster than `run_backtest` because it skips compilation.
|
||||
|
||||
### Input schema
|
||||
|
||||
Same as `run_backtest`, but `skip_compile` is automatically set to `true`.
|
||||
|
||||
### Output schema
|
||||
|
||||
Same as `run_backtest`.
|
||||
|
||||
---
|
||||
|
||||
## `run_backtest_only`
|
||||
|
||||
Backtest only: clean cache → backtest → extract. No analysis phase.
|
||||
|
||||
**When to call:** When you just need raw trade data (deals.csv) and don't need analytics. Fastest option for batch processing.
|
||||
|
||||
### Input schema
|
||||
|
||||
Same as `run_backtest`, but `skip_compile` and `skip_analyze` are automatically set to `true`.
|
||||
|
||||
### Output schema
|
||||
|
||||
Same as `run_backtest` but without `analysis_summary`.
|
||||
|
||||
---
|
||||
|
||||
## `launch_backtest`
|
||||
|
||||
Fire-and-forget mode: compile → clean → launch MT5 backtest, return immediately with job info.
|
||||
|
||||
**When to call:** When you want to launch a backtest without waiting for completion. Use `get_backtest_status` to poll for completion.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
expert: string; // Required. EA name without path or extension
|
||||
symbol?: string; // Trading symbol (default: from config or first available)
|
||||
from_date?: string; // Start date YYYY.MM.DD (default: past complete month)
|
||||
to_date?: string; // End date YYYY.MM.DD (default: past complete month)
|
||||
timeframe?: string; // M1, M5, M15, M30, H1, H4, D1 (default: M5)
|
||||
deposit?: number; // Initial deposit (default: 10000)
|
||||
model?: 0 | 1 | 2; // Tick model (default: 0)
|
||||
set_file?: string; // Path to .set parameter file
|
||||
skip_compile?: boolean; // Skip compilation
|
||||
skip_clean?: boolean; // Skip cache cleaning
|
||||
timeout?: number; // Max time in seconds (default: 900)
|
||||
gui?: boolean; // Enable MT5 visualization
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: true;
|
||||
message: string; // "Backtest launched successfully..."
|
||||
report_id: string; // e.g., "20250122_034455_MyEA_XAUUSD_M5"
|
||||
report_dir: string; // Full path to report directory
|
||||
expert: string;
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
launched_at: string; // ISO8601 timestamp
|
||||
timeout_seconds: number;
|
||||
poll_hint: string; // "Call get_backtest_status with report_dir to check progress"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `get_backtest_status`
|
||||
|
||||
Check progress of a running backtest pipeline launched via `launch_backtest`.
|
||||
|
||||
**When to call:** Poll periodically after calling `launch_backtest` to check completion status.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
report_dir: string; // Report directory path from launch_backtest output
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: true;
|
||||
report_dir: string;
|
||||
status: "completed" | "running" | "failed" | "in_progress" | "not_started";
|
||||
stage: string; // Current pipeline stage: COMPILE, CLEAN, BACKTEST, EXTRACT, ANALYZE, DONE
|
||||
is_complete: boolean; // True if backtest finished successfully
|
||||
mt5_running: boolean; // Whether MT5 process is active
|
||||
report_found: boolean; // Whether report file exists
|
||||
metrics_extracted: boolean;
|
||||
deals_extracted: boolean;
|
||||
elapsed_seconds: number; // Time since launch
|
||||
message: string; // Human-readable status message
|
||||
job?: {
|
||||
report_id: string;
|
||||
expert: string;
|
||||
symbol: string;
|
||||
timeframe: string;
|
||||
launched_at: string;
|
||||
timeout_seconds: number;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `run_optimization`
|
||||
|
||||
Launch genetic parameter optimization as a detached background process.
|
||||
@@ -1237,6 +1359,323 @@ List all `.set` files in the MT5 tester profiles directory with param counts, sw
|
||||
|
||||
---
|
||||
|
||||
## `get_active_account`
|
||||
|
||||
Get current MT5 account session information: login, server, and available symbols. This is essential for pre-flight checks to ensure symbol availability before backtesting.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{} // No parameters
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
ready_for_backtest: boolean; // true if account exists and symbols available
|
||||
account: {
|
||||
login: string;
|
||||
server: string;
|
||||
} | null;
|
||||
server: string; // Active server name
|
||||
available_servers: string[]; // All servers with history data
|
||||
symbols: string[]; // Symbols available for active server
|
||||
symbol_count: number;
|
||||
hint: string; // "Ready for backtesting" or instructions
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `check_symbol_data_status`
|
||||
|
||||
Validate if a symbol has sufficient historical tick data for a specified date range before running backtest. Prevents failed backtests due to missing history data.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
symbol: string; // e.g., "XAUUSDc"
|
||||
from_date: string; // "YYYY.MM.DD"
|
||||
to_date: string; // "YYYY.MM.DD"
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
symbol: string;
|
||||
server: string;
|
||||
has_sufficient_data: boolean;
|
||||
requested_range: { from: string; to: string };
|
||||
data_range: string; // "YYYY.MM.DD - YYYY.MM.DD" or "unknown"
|
||||
years_available: number; // Count of years with data
|
||||
hcc_files_count: number; // Number of history cache files
|
||||
warnings: string[] | null; // Data range issues
|
||||
suggestion: string; // Action recommendation
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `check_mt5_status`
|
||||
|
||||
Check if MT5 terminal is properly installed and configured. Returns comprehensive status of all required components.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{} // No parameters
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
terminal_ready: boolean; // true if all components present
|
||||
checks: {
|
||||
mt5_dir_exists: boolean;
|
||||
terminal64_exe: boolean;
|
||||
metaeditor64_exe: boolean;
|
||||
metatester64_exe: boolean;
|
||||
wine_executable: boolean;
|
||||
wine_path: string | null;
|
||||
};
|
||||
mt5_version: string | null;
|
||||
current_account: {
|
||||
login: string;
|
||||
server: string;
|
||||
} | null;
|
||||
hint: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `get_backtest_history`
|
||||
|
||||
List all backtests previously run for a specific EA and/or symbol with summary metrics. Use for tracking performance over time.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
expert?: string; // Filter by EA name
|
||||
symbol?: string; // Filter by symbol
|
||||
limit?: number; // Max results (default: 10)
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
count: number;
|
||||
total: number;
|
||||
filters: {
|
||||
expert: string | null;
|
||||
symbol: string | null;
|
||||
};
|
||||
history: Array<{
|
||||
report_dir: string;
|
||||
date: string | null;
|
||||
expert: string | null;
|
||||
symbol: string | null;
|
||||
period: string | null;
|
||||
profit: number | null;
|
||||
profit_factor: number | null;
|
||||
expected_payoff: number | null;
|
||||
drawdown_pct: number | null;
|
||||
total_trades: number | null;
|
||||
win_rate: number | null;
|
||||
}>;
|
||||
hint: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `compare_backtests`
|
||||
|
||||
Compare two or more backtest results side-by-side with key metrics analysis. Includes profit/drawdown differences and verdict on which performed better.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
report_dirs: string[]; // List of report directory paths to compare
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
count: number;
|
||||
comparisons: Array<{
|
||||
report_dir: string;
|
||||
expert: string | null;
|
||||
symbol: string | null;
|
||||
net_profit: number | null;
|
||||
profit_factor: number | null;
|
||||
drawdown_pct: number | null;
|
||||
total_trades: number | null;
|
||||
win_rate: number | null;
|
||||
expected_payoff: number | null;
|
||||
recovery_factor: number | null;
|
||||
sharpe_ratio: number | null;
|
||||
}>;
|
||||
analysis: Array<{
|
||||
compare_to: string | null;
|
||||
report: string | null;
|
||||
profit_diff: number;
|
||||
profit_pct_change: number;
|
||||
drawdown_diff: number;
|
||||
profit_factor_diff: number;
|
||||
verdict: "better" | "worse" | "mixed";
|
||||
}> | null;
|
||||
verdict: string | null; // "Best: <report_dir>"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `init_project`
|
||||
|
||||
Create a new MQL5 project with standard directory structure and template files. Supports scalper, swing, grid, and basic templates.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
name: string; // Project name (used for EA filename)
|
||||
template?: "scalper" | "swing" | "grid" | "basic"; // Default: "basic"
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
project_name: string;
|
||||
template: string;
|
||||
created_files: string[]; // Paths to created files
|
||||
hint: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `validate_ea_syntax`
|
||||
|
||||
Perform pre-compile syntax check on MQL5 source file without running full compilation. Detects common issues before expensive MetaEditor compilation.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
path: string; // Path to .mq5 source file
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
valid: boolean;
|
||||
path: string;
|
||||
checks: {
|
||||
has_on_init: boolean;
|
||||
has_on_tick: boolean;
|
||||
has_on_deinit: boolean;
|
||||
lines: number;
|
||||
};
|
||||
errors: Array<{
|
||||
line: number;
|
||||
message: string;
|
||||
severity: "error";
|
||||
}> | null;
|
||||
warnings: Array<{
|
||||
line: number;
|
||||
message: string;
|
||||
severity: "warning";
|
||||
}> | null;
|
||||
hint: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `create_set_template`
|
||||
|
||||
Generate a .set parameter file template based on an EA's input variables. Automatically parses input declarations from source code.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
ea: string; // EA name or path to .mq5/.ex5 file
|
||||
output_path?: string; // Optional custom output path
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
ea: string;
|
||||
inputs_found: number;
|
||||
inputs: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
default: string;
|
||||
description: string | null;
|
||||
}>;
|
||||
set_file: string; // Path to generated file
|
||||
hint: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `export_report`
|
||||
|
||||
Export backtest report to various formats (CSV, JSON, Markdown) for external analysis or sharing.
|
||||
|
||||
### Input schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
report_dir: string; // Path to backtest report directory
|
||||
format?: "csv" | "json" | "md"; // Default: "csv"
|
||||
output_path?: string; // Optional custom output file path
|
||||
}
|
||||
```
|
||||
|
||||
### Output schema
|
||||
|
||||
```typescript
|
||||
{
|
||||
success: boolean;
|
||||
format: string;
|
||||
output_file: string;
|
||||
source: string;
|
||||
hint: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `archive_report`
|
||||
|
||||
Convert a backtest report directory into a compact JSON entry appended to `config/backtest_history.json`. Idempotent — re-archiving the same report is a no-op. Optionally deletes the source directory to reclaim disk space.
|
||||
@@ -1527,6 +1966,31 @@ clean_cache(symbol=XAUUSD, dry_run=true) → preview
|
||||
clean_cache(symbol=XAUUSD) → execute
|
||||
```
|
||||
|
||||
### Pre-flight validation
|
||||
|
||||
```
|
||||
get_active_account() → current login, server, available symbols
|
||||
check_symbol_data_status(symbol=XAUUSD, from=2025.01.01, to=2025.03.31)
|
||||
→ verify data availability before backtest
|
||||
check_mt5_status() → verify MT5 installation and readiness
|
||||
validate_ea_syntax(path=MyEA.mq5) → pre-compile syntax check
|
||||
```
|
||||
|
||||
### Project management
|
||||
|
||||
```
|
||||
init_project(name=MyStrategy, template=scalper) → scaffold new EA with template
|
||||
create_set_template(ea=MyEA) → generate .set from EA inputs
|
||||
export_report(report_dir=..., format=csv) → export to CSV/JSON/Markdown
|
||||
```
|
||||
|
||||
### History and comparison
|
||||
|
||||
```
|
||||
get_backtest_history(expert=MyEA, limit=10) → list past backtests with metrics
|
||||
compare_backtests(report_dirs=["dir1", "dir2"]) → side-by-side comparison
|
||||
```
|
||||
|
||||
### Working with set files
|
||||
|
||||
```
|
||||
|
||||
+67
-12
@@ -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
|
||||
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-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
|
||||
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 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,7 @@ 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
|
||||
```
|
||||
|
||||
Verify:
|
||||
@@ -91,15 +91,70 @@ claude mcp list
|
||||
|
||||
### Windsurf
|
||||
|
||||
Add to `~/.windsurf/config.yaml`:
|
||||
```yaml
|
||||
mcpServers:
|
||||
mt5-quant:
|
||||
command: /path/to/mt5-quant
|
||||
env:
|
||||
MT5_MCP_HOME: /path/to/mt5-mcp
|
||||
Add to `~/.codeium/windsurf/mcp_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"io.github.masdevid/mt5-quant": {
|
||||
"command": "~/.local/bin/mt5-quant",
|
||||
"disabled": false,
|
||||
"registry": "io.github.masdevid/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Cursor
|
||||
|
||||
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"command": "~/.local/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use Settings → MCP → Add Custom MCP.
|
||||
|
||||
### VS Code
|
||||
|
||||
Add to `.vscode/mcp.json` in your workspace:
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"mt5-quant": {
|
||||
"command": "/path/to/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or run `MCP: Add Server` from Command Palette.
|
||||
|
||||
### Claude Desktop
|
||||
|
||||
1. Open Agent panel → Click "..." menu → MCP Servers
|
||||
2. Click "Manage MCP Servers"
|
||||
3. Click "View raw config" or "Edit configuration"
|
||||
4. Add to `mcp_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"command": "/path/to/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
5. Restart Claude Desktop to apply changes
|
||||
|
||||
## 5. Verify Setup
|
||||
|
||||
```bash
|
||||
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
# VS Code 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-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 2: Build from Source
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Configure VS Code
|
||||
|
||||
### Method 1: Command Palette (Recommended)
|
||||
|
||||
1. Press `Cmd/Ctrl + Shift + P`
|
||||
2. Run `MCP: Add Server`
|
||||
3. Choose **Workspace** or **User** scope
|
||||
4. Enter server name: `mt5-quant`
|
||||
5. Enter command: `~/.local/bin/mt5-quant`
|
||||
|
||||
### Method 2: Edit mcp.json Directly
|
||||
|
||||
Add to `.vscode/mcp.json` in your workspace:
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"mt5-quant": {
|
||||
"command": "~/.local/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create the file:
|
||||
|
||||
```bash
|
||||
mkdir -p .vscode
|
||||
cat > .vscode/mcp.json << 'EOF'
|
||||
{
|
||||
"servers": {
|
||||
"mt5-quant": {
|
||||
"command": "~/.local/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
### Method 3: VS Code CLI
|
||||
|
||||
```bash
|
||||
code --add-mcp '{"name":"mt5-quant","command":"~/.local/bin/mt5-quant"}'
|
||||
```
|
||||
|
||||
## Verify Setup
|
||||
|
||||
In Copilot chat, type:
|
||||
|
||||
```
|
||||
Run verify_setup
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Wine: /Applications/MetaTrader 5.app/.../wine64
|
||||
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
|
||||
Display: gui
|
||||
Arch: arch -x86_64
|
||||
```
|
||||
|
||||
## Configuration Locations
|
||||
|
||||
| Scope | Path | Use Case |
|
||||
|-------|------|----------|
|
||||
| Workspace | `.vscode/mcp.json` | Share with team via source control |
|
||||
| User | `~/.vscode/mcp.json` | Personal tools across all projects |
|
||||
| Dev Container | `devcontainer.json` → `customizations.vscode.mcp` | Containerized environments |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### MCP server not appearing
|
||||
|
||||
1. Open **Output** panel (`Cmd/Ctrl + Shift + U`)
|
||||
2. Select **MCP** from dropdown
|
||||
3. Check for connection errors
|
||||
4. Verify the path is absolute
|
||||
|
||||
### Config not found
|
||||
|
||||
The binary auto-detects its config, but you can also:
|
||||
1. Run `setup.sh` to create `config/mt5-quant.yaml`
|
||||
2. Or let the binary auto-discover on first run
|
||||
|
||||
### Dev Container Setup
|
||||
|
||||
Add to `.devcontainer/devcontainer.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"mcp": {
|
||||
"servers": {
|
||||
"mt5-quant": {
|
||||
"command": "/path/to/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Differences from Other IDEs
|
||||
|
||||
VS Code uses `servers` (not `mcpServers`) in the JSON structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": { // ← VS Code uses "servers"
|
||||
"mt5-quant": {
|
||||
"command": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Other platforms use `mcpServers`.
|
||||
|
||||
## Resources
|
||||
|
||||
- [VS Code MCP Documentation](https://code.visualstudio.com/docs/copilot/customization/mcp-servers)
|
||||
- [MCP Configuration Reference](https://code.visualstudio.com/docs/copilot/reference/mcp-configuration)
|
||||
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
|
||||
+34
-13
@@ -6,11 +6,11 @@
|
||||
|
||||
```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
|
||||
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-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
|
||||
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
|
||||
```
|
||||
|
||||
@@ -22,14 +22,28 @@ bash scripts/build-rust.sh
|
||||
|
||||
### 2. Configure Windsurf
|
||||
|
||||
Edit `~/.windsurf/config.yaml`:
|
||||
Edit `~/.codeium/windsurf/mcp_config.json`:
|
||||
|
||||
```yaml
|
||||
mcpServers:
|
||||
mt5-quant:
|
||||
command: /Users/masdevid/jobs/mt5-quant/target/release/mt5-quant
|
||||
env:
|
||||
MT5_MCP_HOME: /Users/masdevid/jobs/mt5-quant
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"io.github.masdevid/mt5-quant": {
|
||||
"command": "~/.local/bin/mt5-quant",
|
||||
"disabled": false,
|
||||
"registry": "io.github.masdevid/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or use the automated setup:
|
||||
|
||||
```bash
|
||||
# Install binary to standard location
|
||||
cp target/release/mt5-quant ~/.local/bin/
|
||||
|
||||
# Then configure
|
||||
bash scripts/setup.sh
|
||||
```
|
||||
|
||||
### 3. Restart Windsurf
|
||||
@@ -66,12 +80,19 @@ scp -r config/mt5-quant.yaml user@server:~/.config/mt5-quant/config/
|
||||
- **NO Python required!**
|
||||
|
||||
### Windsurf Config on Target Machine
|
||||
```yaml
|
||||
mcpServers:
|
||||
mt5-quant:
|
||||
command: /usr/local/bin/mt5-quant
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mt5-quant": {
|
||||
"command": "/usr/local/bin/mt5-quant"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The binary auto-detects its config location. No environment variables needed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### MCP server not appearing
|
||||
|
||||
+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
|
||||
+468
-29
@@ -7,6 +7,16 @@
|
||||
# --keep-last N Keep only last N backtest reports (default: 20)
|
||||
# --claude-code Generate CLAUDE.md template and .claude/hooks/user-prompt-submit.sh
|
||||
# (skips main config wizard — run standalone or alongside normal setup)
|
||||
#
|
||||
# MCP Auto-Registration (per official 2025 docs):
|
||||
# Automatically detects and registers with available MCP platforms:
|
||||
# - Claude Code : via 'claude mcp add' (stored in ~/.claude.json)
|
||||
# - Windsurf : ~/.codeium/windsurf/mcp_config.json (JSON, mcpServers)
|
||||
# - Cursor : ~/.cursor/mcp.json (JSON, mcpServers)
|
||||
# - VS Code : .vscode/mcp.json (JSON, servers - not mcpServers)
|
||||
# - Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json
|
||||
#
|
||||
# Previous installations are auto-detected and uninstalled before re-registering.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
@@ -648,6 +658,30 @@ main() {
|
||||
_bold "MT5-Quant setup — auto-detecting Wine and MT5 paths"
|
||||
echo "────────────────────────────────────────────────────"
|
||||
|
||||
# ── Check for previous installation ────────────────────────────────────────
|
||||
if _check_any_existing_registration; then
|
||||
_yellow "Previous mt5-quant MCP installation detected on one or more platforms"
|
||||
echo " New path: ${REPO_DIR}/server/main.py"
|
||||
|
||||
local reinstall_ans="yes"
|
||||
if ! $AUTO_YES; then
|
||||
reinstall_ans=$(_ask "Unregister all previous installations and reinstall?" "yes")
|
||||
fi
|
||||
|
||||
if [[ "$reinstall_ans" =~ ^[Yy] ]]; then
|
||||
_unregister_all_platforms || {
|
||||
if ! $AUTO_YES; then
|
||||
local force_ans
|
||||
force_ans=$(_ask "Some platforms failed to unregister. Continue anyway?" "no")
|
||||
[[ ! "$force_ans" =~ ^[Yy] ]] && exit 1
|
||||
fi
|
||||
}
|
||||
else
|
||||
echo "Aborted — keeping existing installations."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Check if config already exists ───────────────────────────────────────
|
||||
if [[ -f "$CONFIG_OUT" ]] && ! $AUTO_YES; then
|
||||
_yellow "Config already exists: $CONFIG_OUT"
|
||||
@@ -784,49 +818,454 @@ main() {
|
||||
_ok "Written: $CONFIG_OUT"
|
||||
echo " Tip: see config/example.set for optimization .set file format"
|
||||
|
||||
# ── Register with Claude Code ──────────────────────────────────────────────
|
||||
_offer_mcp_register
|
||||
# ── Register with all detected MCP platforms ─────────────────────────────
|
||||
_register_all_mcp_platforms
|
||||
|
||||
echo ""
|
||||
_green "Setup complete!"
|
||||
echo ""
|
||||
}
|
||||
|
||||
# ── MCP registration ──────────────────────────────────────────────────────────
|
||||
_offer_mcp_register() {
|
||||
echo ""
|
||||
_bold "Registering with Claude Code..."
|
||||
# ── MCP Platform Detection & Registration ───────────────────────────────────
|
||||
|
||||
if ! command -v claude &>/dev/null; then
|
||||
_warn "claude CLI not found — register manually:"
|
||||
# Detect available MCP platforms and return them as a list
|
||||
detect_mcp_platforms() {
|
||||
local platforms=()
|
||||
|
||||
# Claude Code
|
||||
if command -v claude &>/dev/null; then
|
||||
platforms+=("claude")
|
||||
fi
|
||||
|
||||
# Windsurf (uses ~/.codeium/windsurf/mcp_config.json)
|
||||
if [[ -d "$HOME/.codeium/windsurf" ]] || [[ -d "$HOME/.windsurf" ]] || command -v windsurf &>/dev/null; then
|
||||
platforms+=("windsurf")
|
||||
fi
|
||||
|
||||
# Cursor (uses ~/.cursor/mcp.json)
|
||||
if [[ -d "$HOME/.cursor" ]] || [[ -d "$HOME/Library/Application Support/Cursor" ]] || command -v cursor &>/dev/null; then
|
||||
platforms+=("cursor")
|
||||
fi
|
||||
|
||||
# VS Code (uses .vscode/mcp.json or user profile)
|
||||
if [[ -d "$HOME/.vscode" ]] || [[ -d "$HOME/Library/Application Support/Code" ]] || command -v code &>/dev/null; then
|
||||
platforms+=("vscode")
|
||||
fi
|
||||
|
||||
printf '%s\n' "${platforms[@]:-}"
|
||||
}
|
||||
|
||||
# Check if mt5-quant is registered on a specific platform
|
||||
_is_registered_on_platform() {
|
||||
local platform="$1"
|
||||
case "$platform" in
|
||||
claude)
|
||||
if command -v claude &>/dev/null; then
|
||||
local mcp_list
|
||||
mcp_list=$(claude mcp list 2>/dev/null || true)
|
||||
echo "$mcp_list" | grep -q "mt5-quant"
|
||||
return $?
|
||||
fi
|
||||
return 1
|
||||
;;
|
||||
windsurf)
|
||||
local config_file="$HOME/.codeium/windsurf/mcp_config.json"
|
||||
[[ -f "$config_file" ]] && grep -q '"mt5-quant"' "$config_file" 2>/dev/null
|
||||
return $?
|
||||
;;
|
||||
cursor)
|
||||
local config_file
|
||||
config_file="$HOME/.cursor/mcp.json"
|
||||
[[ -f "$config_file" ]] && grep -q "mt5-quant" "$config_file" 2>/dev/null
|
||||
return $?
|
||||
;;
|
||||
vscode)
|
||||
# VS Code uses .vscode/mcp.json in workspace or user profile
|
||||
local workspace_config=".vscode/mcp.json"
|
||||
local user_config
|
||||
user_config="$HOME/.vscode/mcp.json"
|
||||
[[ -f "$workspace_config" ]] && grep -q '"mt5-quant"' "$workspace_config" 2>/dev/null && return 0
|
||||
[[ -f "$user_config" ]] && grep -q '"mt5-quant"' "$user_config" 2>/dev/null && return 0
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
# Get registered path for a platform
|
||||
_get_platform_mcp_path() {
|
||||
local platform="$1"
|
||||
case "$platform" in
|
||||
claude)
|
||||
if command -v claude &>/dev/null; then
|
||||
local mcp_list
|
||||
mcp_list=$(claude mcp list 2>/dev/null || true)
|
||||
echo "$mcp_list" | grep "mt5-quant" | head -1 | sed -E 's/.*--[[:space:]]*//' | tr -d ' '
|
||||
fi
|
||||
;;
|
||||
windsurf)
|
||||
local config_file="$HOME/.codeium/windsurf/mcp_config.json"
|
||||
if [[ -f "$config_file" ]]; then
|
||||
grep -A3 '"mt5-quant"' "$config_file" 2>/dev/null | grep '"command"' | sed 's/.*"command":[[:space:]]*"\([^"]*\)".*/\1/'
|
||||
fi
|
||||
;;
|
||||
cursor)
|
||||
local config_file="$HOME/.cursor/mcp.json"
|
||||
if [[ -f "$config_file" ]]; then
|
||||
grep -A3 '"mt5-quant"' "$config_file" 2>/dev/null | grep '"command"' | sed 's/.*"command":[[:space:]]*"\([^"]*\)".*/\1/'
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Unregister from a specific platform
|
||||
_unregister_from_platform() {
|
||||
local platform="$1"
|
||||
echo ""
|
||||
_bold "Unregistering from $platform..."
|
||||
|
||||
case "$platform" in
|
||||
claude)
|
||||
if ! command -v claude &>/dev/null; then
|
||||
_warn "claude CLI not found"
|
||||
return 1
|
||||
fi
|
||||
local out
|
||||
out=$(claude mcp remove mt5-quant 2>&1) || true
|
||||
if echo "$out" | grep -qi "removed\|success\|deleted"; then
|
||||
_ok "Unregistered from Claude Code"
|
||||
return 0
|
||||
elif echo "$out" | grep -qi "not found\|does not exist"; then
|
||||
_ok "No existing registration on Claude Code"
|
||||
return 0
|
||||
else
|
||||
_warn "Unregister result: $out"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
windsurf)
|
||||
local config_file="$HOME/.codeium/windsurf/mcp_config.json"
|
||||
if [[ -f "$config_file" ]]; then
|
||||
# Remove mt5-quant from JSON using Python
|
||||
if command -v python3 &>/dev/null; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
with open('$config_file') as f:
|
||||
data = json.load(f)
|
||||
if 'mcpServers' in data and 'mt5-quant' in data['mcpServers']:
|
||||
del data['mcpServers']['mt5-quant']
|
||||
with open('$config_file', 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
" && { _ok "Unregistered from Windsurf"; return 0; }
|
||||
fi
|
||||
_warn "Could not auto-unregister from Windsurf — edit $config_file manually"
|
||||
return 1
|
||||
fi
|
||||
_ok "No existing registration on Windsurf"
|
||||
return 0
|
||||
;;
|
||||
cursor)
|
||||
local config_file="$HOME/.cursor/mcp.json"
|
||||
if [[ -f "$config_file" ]]; then
|
||||
# Remove mt5-quant from JSON using Python if available, or sed as fallback
|
||||
if command -v python3 &>/dev/null; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
with open('$config_file') as f:
|
||||
data = json.load(f)
|
||||
if 'mcpServers' in data and 'mt5-quant' in data['mcpServers']:
|
||||
del data['mcpServers']['mt5-quant']
|
||||
with open('$config_file', 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
" && { _ok "Unregistered from Cursor"; return 0; }
|
||||
fi
|
||||
_warn "Could not auto-unregister from Cursor — edit $config_file manually"
|
||||
return 1
|
||||
fi
|
||||
_ok "No existing registration on Cursor"
|
||||
return 0
|
||||
;;
|
||||
vscode)
|
||||
# VS Code uses 'servers' (not 'mcpServers') in mcp.json
|
||||
local workspace_config=".vscode/mcp.json"
|
||||
local user_config="$HOME/.vscode/mcp.json"
|
||||
local config_file=""
|
||||
|
||||
# Determine which config file to use
|
||||
if [[ -f "$workspace_config" ]] && grep -q '"mt5-quant"' "$workspace_config" 2>/dev/null; then
|
||||
config_file="$workspace_config"
|
||||
elif [[ -f "$user_config" ]] && grep -q '"mt5-quant"' "$user_config" 2>/dev/null; then
|
||||
config_file="$user_config"
|
||||
fi
|
||||
|
||||
if [[ -n "$config_file" ]]; then
|
||||
if command -v python3 &>/dev/null; then
|
||||
python3 -c "
|
||||
import json, sys
|
||||
with open('$config_file') as f:
|
||||
data = json.load(f)
|
||||
if 'servers' in data and 'mt5-quant' in data['servers']:
|
||||
del data['servers']['mt5-quant']
|
||||
with open('$config_file', 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
" && { _ok "Unregistered from VS Code ($config_file)"; return 0; }
|
||||
fi
|
||||
_warn "Could not auto-unregister from VS Code — edit $config_file manually"
|
||||
return 1
|
||||
fi
|
||||
_ok "No existing registration on VS Code"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Register with a specific platform
|
||||
_register_with_platform() {
|
||||
local platform="$1"
|
||||
local use_binary="${2:-false}"
|
||||
|
||||
# Determine command path - use standard installation location
|
||||
local cmd_path
|
||||
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"
|
||||
_warn "Using project build at $cmd_path"
|
||||
_warn "Consider installing to standard location: cp $cmd_path ~/.local/bin/"
|
||||
else
|
||||
_fail "mt5-quant not found at $default_install or ${REPO_DIR}/target/release/"
|
||||
return 1
|
||||
fi
|
||||
|
||||
case "$platform" in
|
||||
claude)
|
||||
local out
|
||||
out=$(claude mcp add mt5-quant -- $cmd_path 2>&1) || true
|
||||
if echo "$out" | grep -qi "already\|exists"; then
|
||||
_ok "Already registered on Claude Code"
|
||||
elif echo "$out" | grep -qi "error\|failed"; then
|
||||
_warn "Claude Code registration failed: $out"
|
||||
return 1
|
||||
else
|
||||
_ok "Registered on Claude Code"
|
||||
fi
|
||||
;;
|
||||
windsurf)
|
||||
local config_file="$HOME/.codeium/windsurf/mcp_config.json"
|
||||
mkdir -p "$(dirname "$config_file")"
|
||||
|
||||
# Create or update JSON config
|
||||
if command -v python3 &>/dev/null; then
|
||||
python3 -c "
|
||||
import json, os
|
||||
config_path = '$config_file'
|
||||
data = {'mcpServers': {}}
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = json.load(f)
|
||||
except:
|
||||
pass
|
||||
if 'mcpServers' not in data:
|
||||
data['mcpServers'] = {}
|
||||
|
||||
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:
|
||||
json.dump(data, f, indent=2)
|
||||
" && { _ok "Registered on Windsurf ($config_file)"; return 0; }
|
||||
else
|
||||
_warn "Python3 required for Windsurf registration"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
cursor)
|
||||
local config_file="$HOME/.cursor/mcp.json"
|
||||
mkdir -p "$(dirname "$config_file")"
|
||||
|
||||
# Create or update JSON config
|
||||
if command -v python3 &>/dev/null; then
|
||||
python3 -c "
|
||||
import json, os
|
||||
config_path = '$config_file'
|
||||
data = {'mcpServers': {}}
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = json.load(f)
|
||||
except:
|
||||
pass
|
||||
if 'mcpServers' not in data:
|
||||
data['mcpServers'] = {}
|
||||
|
||||
data['mcpServers']['mt5-quant'] = {
|
||||
'command': '$cmd_path'
|
||||
}
|
||||
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
" && { _ok "Registered on Cursor ($config_file)"; return 0; }
|
||||
else
|
||||
_warn "Python3 required for Cursor registration"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
vscode)
|
||||
# VS Code uses .vscode/mcp.json (workspace) or user profile
|
||||
# Format: { "servers": { "name": { "command": "...", "args": [...], "env": {...} } }
|
||||
local workspace_config=".vscode/mcp.json"
|
||||
mkdir -p ".vscode"
|
||||
|
||||
if command -v python3 &>/dev/null; then
|
||||
python3 -c "
|
||||
import json, os
|
||||
config_path = '$workspace_config'
|
||||
data = {'servers': {}}
|
||||
if os.path.exists(config_path):
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
data = json.load(f)
|
||||
except:
|
||||
pass
|
||||
if 'servers' not in data:
|
||||
data['servers'] = {}
|
||||
|
||||
# For stdio servers, VS Code uses 'command' and optional 'args'
|
||||
data['servers']['mt5-quant'] = {
|
||||
'command': '$cmd_path'
|
||||
}
|
||||
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
" && { _ok "Registered on VS Code ($workspace_config)"; return 0; }
|
||||
else
|
||||
_warn "Python3 required for VS Code registration"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Check for any existing registrations across all platforms
|
||||
_check_any_existing_registration() {
|
||||
local platforms=()
|
||||
while IFS= read -r platform; do
|
||||
[[ -n "$platform" ]] && platforms+=("$platform")
|
||||
done < <(detect_mcp_platforms)
|
||||
|
||||
local found=false
|
||||
for platform in "${platforms[@]}"; do
|
||||
if _is_registered_on_platform "$platform"; then
|
||||
found=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
$found
|
||||
}
|
||||
|
||||
# Unregister from all platforms
|
||||
_unregister_all_platforms() {
|
||||
local platforms=()
|
||||
while IFS= read -r platform; do
|
||||
[[ -n "$platform" ]] && platforms+=("$platform")
|
||||
done < <(detect_mcp_platforms)
|
||||
|
||||
for platform in "${platforms[@]}"; do
|
||||
_is_registered_on_platform "$platform" && _unregister_from_platform "$platform"
|
||||
done
|
||||
}
|
||||
|
||||
# Main registration function - detects platforms and registers with all
|
||||
_register_all_mcp_platforms() {
|
||||
echo ""
|
||||
_bold "Detecting MCP platforms..."
|
||||
|
||||
local platforms=()
|
||||
while IFS= read -r platform; do
|
||||
[[ -n "$platform" ]] && platforms+=("$platform")
|
||||
done < <(detect_mcp_platforms)
|
||||
|
||||
if [[ ${#platforms[@]} -eq 0 ]]; then
|
||||
_warn "No MCP platforms detected (Claude, Windsurf, Cursor, VS Code)"
|
||||
echo ""
|
||||
echo " claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
|
||||
echo " Manual registration required:"
|
||||
echo " - Claude Code: claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
|
||||
echo " - Windsurf: Edit ~/.codeium/windsurf/mcp_config.json"
|
||||
echo " - Cursor: Edit ~/.cursor/mcp.json"
|
||||
echo " - VS Code: Edit .vscode/mcp.json (workspace) or use MCP: Add Server command"
|
||||
echo " - Claude Desktop: Edit ~/Library/Application Support/Claude/claude_desktop_config.json"
|
||||
echo ""
|
||||
return
|
||||
fi
|
||||
|
||||
local register=true
|
||||
if ! $AUTO_YES; then
|
||||
local ans
|
||||
ans=$(_ask "Register mt5-quant with Claude Code now?" "yes")
|
||||
[[ ! "$ans" =~ ^[Yy] ]] && register=false
|
||||
_ok "Found platforms: ${platforms[*]}"
|
||||
|
||||
# Check for existing registrations
|
||||
local has_existing=false
|
||||
for platform in "${platforms[@]}"; do
|
||||
if _is_registered_on_platform "$platform"; then
|
||||
has_existing=true
|
||||
local old_path
|
||||
old_path=$(_get_platform_mcp_path "$platform")
|
||||
_yellow "Existing registration detected on $platform"
|
||||
[[ -n "$old_path" ]] && echo " Current path: $old_path"
|
||||
fi
|
||||
done
|
||||
|
||||
# Prompt for reinstall if any existing registrations found
|
||||
if $has_existing && ! $AUTO_YES; then
|
||||
echo ""
|
||||
local reinstall_ans
|
||||
reinstall_ans=$(_ask "Unregister existing installations and reinstall on all platforms?" "yes")
|
||||
if [[ ! "$reinstall_ans" =~ ^[Yy] ]]; then
|
||||
echo " Keeping existing registrations."
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
if $register; then
|
||||
local out
|
||||
out=$(claude mcp add mt5-quant -- python3 "${REPO_DIR}/server/main.py" 2>&1) || true
|
||||
if echo "$out" | grep -qi "already\|exists"; then
|
||||
_ok "Already registered (no change needed)"
|
||||
elif echo "$out" | grep -qi "error\|failed"; then
|
||||
_warn "Registration failed: $out"
|
||||
echo " Run manually: claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
|
||||
else
|
||||
_ok "Registered: claude mcp add mt5-quant"
|
||||
fi
|
||||
else
|
||||
echo " Skipped. Run manually:"
|
||||
echo " claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
|
||||
fi
|
||||
# Unregister from all platforms first
|
||||
for platform in "${platforms[@]}"; do
|
||||
_is_registered_on_platform "$platform" && _unregister_from_platform "$platform"
|
||||
done
|
||||
|
||||
# Register with all detected platforms
|
||||
echo ""
|
||||
_bold "Registering mt5-quant MCP..."
|
||||
|
||||
for platform in "${platforms[@]}"; do
|
||||
# Use binary for Windsurf/Cursor, Python for Claude
|
||||
case "$platform" in
|
||||
windsurf|cursor)
|
||||
_register_with_platform "$platform" true
|
||||
;;
|
||||
*)
|
||||
_register_with_platform "$platform" false
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
_green "MCP registration complete!"
|
||||
echo ""
|
||||
echo " Registered on: ${platforms[*]}"
|
||||
echo ""
|
||||
echo " Config locations:"
|
||||
[[ " ${platforms[*]} " =~ " claude " ]] && echo " Claude Code: ~/.claude.json (managed via CLI)"
|
||||
[[ " ${platforms[*]} " =~ " windsurf " ]] && echo " Windsurf: ~/.codeium/windsurf/mcp_config.json"
|
||||
[[ " ${platforms[*]} " =~ " cursor " ]] && echo " Cursor: ~/.cursor/mcp.json"
|
||||
[[ " ${platforms[*]} " =~ " vscode " ]] && echo " VS Code: .vscode/mcp.json"
|
||||
echo ""
|
||||
echo " Binary: ${REPO_DIR}/target/release/mt5-quant"
|
||||
echo " Python: ${REPO_DIR}/server/main.py"
|
||||
echo ""
|
||||
}
|
||||
|
||||
main
|
||||
|
||||
+6
-5
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/modelcontextprotocol/specification/main/schema/2024-11-05/schema.json",
|
||||
"$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. Compile, backtest, analyze MQL5 EAs on macOS/Linux.",
|
||||
"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.27.0",
|
||||
"version": "1.31.5",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "mcpb",
|
||||
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.27.0/mcp-mt5-quant-macos-arm64.tar.gz",
|
||||
"fileSha256": "e4dd26f2fa920e22575c88546e4d0dc54ec34d51950f3c58bfdf3a3f10b8a9a4",
|
||||
"version": "1.31.5",
|
||||
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.31.5/mcp-mt5-quant-macos-arm64.tar.gz",
|
||||
"fileSha256": "TBD_CI_WILL_UPDATE",
|
||||
"transport": {
|
||||
"type": "stdio"
|
||||
},
|
||||
|
||||
+610
-1
@@ -1,4 +1,4 @@
|
||||
use chrono::{DateTime, NaiveDateTime};
|
||||
use chrono::{DateTime, Datelike, NaiveDateTime, Timelike};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -489,3 +489,612 @@ pub struct ConcurrentPeak {
|
||||
pub peak_open: i32,
|
||||
pub peak_time: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfitDistribution {
|
||||
pub small_wins: i32,
|
||||
pub medium_wins: i32,
|
||||
pub large_wins: i32,
|
||||
pub small_losses: i32,
|
||||
pub medium_losses: i32,
|
||||
pub large_losses: i32,
|
||||
pub small_win_pnl: f64,
|
||||
pub medium_win_pnl: f64,
|
||||
pub large_win_pnl: f64,
|
||||
pub small_loss_pnl: f64,
|
||||
pub medium_loss_pnl: f64,
|
||||
pub large_loss_pnl: f64,
|
||||
pub buckets: Vec<ProfitBucket>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfitBucket {
|
||||
pub range: String,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
pub count: i32,
|
||||
pub total_pnl: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TimePerformance {
|
||||
pub by_hour: Vec<HourPerformance>,
|
||||
pub by_day: Vec<DayPerformance>,
|
||||
pub best_hour: i32,
|
||||
pub worst_hour: i32,
|
||||
pub best_day: String,
|
||||
pub worst_day: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HourPerformance {
|
||||
pub hour: i32,
|
||||
pub trades: i32,
|
||||
pub wins: i32,
|
||||
pub total_pnl: f64,
|
||||
pub win_rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DayPerformance {
|
||||
pub day: String,
|
||||
pub day_num: i32,
|
||||
pub trades: i32,
|
||||
pub wins: i32,
|
||||
pub total_pnl: f64,
|
||||
pub win_rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HoldTimeAnalysis {
|
||||
pub avg_hold_minutes: f64,
|
||||
pub median_hold_minutes: f64,
|
||||
pub buckets: Vec<HoldTimeBucket>,
|
||||
pub correlation_with_profit: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HoldTimeBucket {
|
||||
pub range: String,
|
||||
pub min_minutes: f64,
|
||||
pub max_minutes: f64,
|
||||
pub count: i32,
|
||||
pub avg_profit: f64,
|
||||
pub total_pnl: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LayerPerformance {
|
||||
pub layer: i32,
|
||||
pub trades: i32,
|
||||
pub wins: i32,
|
||||
pub total_pnl: f64,
|
||||
pub win_rate: f64,
|
||||
pub avg_volume: f64,
|
||||
pub avg_profit: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VolumeAnalysis {
|
||||
pub correlation_with_profit: f64,
|
||||
pub by_volume_bucket: Vec<VolumeBucket>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VolumeBucket {
|
||||
pub volume_range: String,
|
||||
pub min_volume: f64,
|
||||
pub max_volume: f64,
|
||||
pub trades: i32,
|
||||
pub avg_profit: f64,
|
||||
pub total_pnl: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CostAnalysis {
|
||||
pub total_commission: f64,
|
||||
pub total_swap: f64,
|
||||
pub commission_pct_of_profit: f64,
|
||||
pub swap_pct_of_profit: f64,
|
||||
pub avg_commission_per_trade: f64,
|
||||
pub avg_swap_per_trade: f64,
|
||||
pub net_profit_before_costs: f64,
|
||||
pub cost_impact_on_win_rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EfficiencyAnalysis {
|
||||
pub profit_per_hour: f64,
|
||||
pub profit_per_day: f64,
|
||||
pub profit_per_trade_hour: f64,
|
||||
pub avg_trade_duration_hours: f64,
|
||||
pub annualized_return_pct: f64,
|
||||
pub trades_per_day: f64,
|
||||
}
|
||||
|
||||
impl DealAnalyzer {
|
||||
pub fn profit_distribution(&self, deals: &[Deal]) -> ProfitDistribution {
|
||||
let closed: Vec<&Deal> = deals
|
||||
.iter()
|
||||
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
|
||||
.collect();
|
||||
|
||||
let mut small_wins = 0;
|
||||
let mut medium_wins = 0;
|
||||
let mut large_wins = 0;
|
||||
let mut small_losses = 0;
|
||||
let mut medium_losses = 0;
|
||||
let mut large_losses = 0;
|
||||
let mut small_win_pnl = 0.0;
|
||||
let mut medium_win_pnl = 0.0;
|
||||
let mut large_win_pnl = 0.0;
|
||||
let mut small_loss_pnl = 0.0;
|
||||
let mut medium_loss_pnl = 0.0;
|
||||
let mut large_loss_pnl = 0.0;
|
||||
|
||||
for deal in &closed {
|
||||
let profit = deal.profit;
|
||||
if profit > 0.0 {
|
||||
if profit < 50.0 {
|
||||
small_wins += 1;
|
||||
small_win_pnl += profit;
|
||||
} else if profit < 200.0 {
|
||||
medium_wins += 1;
|
||||
medium_win_pnl += profit;
|
||||
} else {
|
||||
large_wins += 1;
|
||||
large_win_pnl += profit;
|
||||
}
|
||||
} else {
|
||||
let loss = profit.abs();
|
||||
if loss < 50.0 {
|
||||
small_losses += 1;
|
||||
small_loss_pnl += profit;
|
||||
} else if loss < 200.0 {
|
||||
medium_losses += 1;
|
||||
medium_loss_pnl += profit;
|
||||
} else {
|
||||
large_losses += 1;
|
||||
large_loss_pnl += profit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create detailed buckets
|
||||
let bucket_ranges = [
|
||||
(-999999.0, -500.0, "Loss $500+"),
|
||||
(-500.0, -200.0, "Loss $200-500"),
|
||||
(-200.0, -50.0, "Loss $50-200"),
|
||||
(-50.0, 0.0, "Loss $0-50"),
|
||||
(0.0, 50.0, "Win $0-50"),
|
||||
(50.0, 200.0, "Win $50-200"),
|
||||
(200.0, 500.0, "Win $200-500"),
|
||||
(500.0, 999999.0, "Win $500+"),
|
||||
];
|
||||
|
||||
let mut buckets: Vec<ProfitBucket> = bucket_ranges
|
||||
.iter()
|
||||
.map(|(min, max, range)| {
|
||||
let count = closed
|
||||
.iter()
|
||||
.filter(|d| d.profit >= *min && d.profit < *max)
|
||||
.count() as i32;
|
||||
let total_pnl: f64 = closed
|
||||
.iter()
|
||||
.filter(|d| d.profit >= *min && d.profit < *max)
|
||||
.map(|d| d.profit)
|
||||
.sum();
|
||||
ProfitBucket {
|
||||
range: range.to_string(),
|
||||
min: *min,
|
||||
max: *max,
|
||||
count,
|
||||
total_pnl: (total_pnl * 100.0).round() / 100.0,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Remove empty buckets
|
||||
buckets.retain(|b| b.count > 0);
|
||||
|
||||
ProfitDistribution {
|
||||
small_wins,
|
||||
medium_wins,
|
||||
large_wins,
|
||||
small_losses,
|
||||
medium_losses,
|
||||
large_losses,
|
||||
small_win_pnl: (small_win_pnl * 100.0).round() / 100.0,
|
||||
medium_win_pnl: (medium_win_pnl * 100.0).round() / 100.0,
|
||||
large_win_pnl: (large_win_pnl * 100.0).round() / 100.0,
|
||||
small_loss_pnl: (small_loss_pnl * 100.0).round() / 100.0,
|
||||
medium_loss_pnl: (medium_loss_pnl * 100.0).round() / 100.0,
|
||||
large_loss_pnl: (large_loss_pnl * 100.0).round() / 100.0,
|
||||
buckets,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn time_performance(&self, deals: &[Deal]) -> TimePerformance {
|
||||
let closed: Vec<&Deal> = deals
|
||||
.iter()
|
||||
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
|
||||
.collect();
|
||||
|
||||
let mut hourly: HashMap<i32, (i32, i32, f64)> = HashMap::new();
|
||||
let mut daily: HashMap<String, (i32, i32, f64, i32)> = HashMap::new();
|
||||
|
||||
for deal in &closed {
|
||||
if let Some(dt) = Self::parse_datetime(&deal.time) {
|
||||
let hour = dt.hour() as i32;
|
||||
let day_num = dt.weekday().num_days_from_monday() as i32;
|
||||
let day_name = match dt.weekday() {
|
||||
chrono::Weekday::Mon => "Mon",
|
||||
chrono::Weekday::Tue => "Tue",
|
||||
chrono::Weekday::Wed => "Wed",
|
||||
chrono::Weekday::Thu => "Thu",
|
||||
chrono::Weekday::Fri => "Fri",
|
||||
chrono::Weekday::Sat => "Sat",
|
||||
chrono::Weekday::Sun => "Sun",
|
||||
}.to_string();
|
||||
|
||||
let entry = hourly.entry(hour).or_insert((0, 0, 0.0));
|
||||
entry.0 += 1;
|
||||
entry.2 += deal.profit;
|
||||
if deal.profit > 0.0 {
|
||||
entry.1 += 1;
|
||||
}
|
||||
|
||||
let day_entry = daily.entry(day_name.clone()).or_insert((0, 0, 0.0, day_num));
|
||||
day_entry.0 += 1;
|
||||
day_entry.2 += deal.profit;
|
||||
if deal.profit > 0.0 {
|
||||
day_entry.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut by_hour: Vec<HourPerformance> = hourly
|
||||
.into_iter()
|
||||
.map(|(hour, (trades, wins, total_pnl))| HourPerformance {
|
||||
hour,
|
||||
trades,
|
||||
wins,
|
||||
total_pnl: (total_pnl * 100.0).round() / 100.0,
|
||||
win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 },
|
||||
})
|
||||
.collect();
|
||||
by_hour.sort_by_key(|h| h.hour);
|
||||
|
||||
let mut by_day: Vec<DayPerformance> = daily
|
||||
.into_iter()
|
||||
.map(|(day, (trades, wins, total_pnl, day_num))| DayPerformance {
|
||||
day: day.clone(),
|
||||
day_num,
|
||||
trades,
|
||||
wins,
|
||||
total_pnl: (total_pnl * 100.0).round() / 100.0,
|
||||
win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 },
|
||||
})
|
||||
.collect();
|
||||
by_day.sort_by_key(|d| d.day_num);
|
||||
|
||||
let best_hour = by_hour.iter().max_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|h| h.hour).unwrap_or(-1);
|
||||
let worst_hour = by_hour.iter().min_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|h| h.hour).unwrap_or(-1);
|
||||
let best_day = by_day.iter().max_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|d| d.day.clone()).unwrap_or_default();
|
||||
let worst_day = by_day.iter().min_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|d| d.day.clone()).unwrap_or_default();
|
||||
|
||||
TimePerformance {
|
||||
by_hour,
|
||||
by_day,
|
||||
best_hour,
|
||||
worst_hour,
|
||||
best_day,
|
||||
worst_day,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hold_time_analysis(&self, deals: &[Deal]) -> HoldTimeAnalysis {
|
||||
let mut hold_times: Vec<(f64, f64)> = Vec::new(); // (hold_minutes, profit)
|
||||
let mut open_pos: HashMap<String, DateTime<chrono::Utc>> = HashMap::new();
|
||||
|
||||
for deal in deals {
|
||||
let entry = deal.entry.to_lowercase();
|
||||
if let Some(dt) = Self::parse_datetime(&deal.time) {
|
||||
if entry.contains("in") && !entry.contains("out") {
|
||||
open_pos.insert(deal.order.clone(), dt);
|
||||
} else if entry.contains("out") && deal.profit != 0.0 {
|
||||
if let Some(in_time) = open_pos.remove(&deal.order) {
|
||||
let hold_minutes = (dt - in_time).num_seconds() as f64 / 60.0;
|
||||
if hold_minutes > 0.0 {
|
||||
hold_times.push((hold_minutes, deal.profit));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hold_times.is_empty() {
|
||||
return HoldTimeAnalysis {
|
||||
avg_hold_minutes: 0.0,
|
||||
median_hold_minutes: 0.0,
|
||||
buckets: vec![],
|
||||
correlation_with_profit: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
let avg_hold = hold_times.iter().map(|(h, _)| *h).sum::<f64>() / hold_times.len() as f64;
|
||||
let mut sorted_hold: Vec<f64> = hold_times.iter().map(|(h, _)| *h).collect();
|
||||
sorted_hold.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let median_hold = sorted_hold[sorted_hold.len() / 2];
|
||||
|
||||
// Calculate correlation
|
||||
let n = hold_times.len() as f64;
|
||||
let sum_x = hold_times.iter().map(|(h, _)| *h).sum::<f64>();
|
||||
let sum_y = hold_times.iter().map(|(_, p)| *p).sum::<f64>();
|
||||
let sum_xy = hold_times.iter().map(|(h, p)| h * p).sum::<f64>();
|
||||
let sum_x2 = hold_times.iter().map(|(h, _)| h * h).sum::<f64>();
|
||||
let sum_y2 = hold_times.iter().map(|(_, p)| p * p).sum::<f64>();
|
||||
|
||||
let correlation = if n > 1.0 {
|
||||
let numerator = n * sum_xy - sum_x * sum_y;
|
||||
let denominator = ((n * sum_x2 - sum_x * sum_x) * (n * sum_y2 - sum_y * sum_y)).sqrt();
|
||||
if denominator > 0.0 { numerator / denominator } else { 0.0 }
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Create buckets
|
||||
let bucket_defs = [
|
||||
(0.0, 15.0, "< 15 min"),
|
||||
(15.0, 60.0, "15-60 min"),
|
||||
(60.0, 240.0, "1-4 hours"),
|
||||
(240.0, 1440.0, "4-24 hours"),
|
||||
(1440.0, 10080.0, "1-7 days"),
|
||||
(10080.0, 999999.0, "> 7 days"),
|
||||
];
|
||||
|
||||
let buckets: Vec<HoldTimeBucket> = bucket_defs
|
||||
.iter()
|
||||
.map(|(min, max, range)| {
|
||||
let bucket_deals: Vec<(f64, f64)> = hold_times
|
||||
.iter()
|
||||
.filter(|(h, _)| *h >= *min && *h < *max)
|
||||
.cloned()
|
||||
.collect();
|
||||
let count = bucket_deals.len() as i32;
|
||||
let total_pnl: f64 = bucket_deals.iter().map(|(_, p)| *p).sum();
|
||||
let avg_profit = if count > 0 { total_pnl / count as f64 } else { 0.0 };
|
||||
|
||||
HoldTimeBucket {
|
||||
range: range.to_string(),
|
||||
min_minutes: *min,
|
||||
max_minutes: *max,
|
||||
count,
|
||||
avg_profit: (avg_profit * 100.0).round() / 100.0,
|
||||
total_pnl: (total_pnl * 100.0).round() / 100.0,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
HoldTimeAnalysis {
|
||||
avg_hold_minutes: (avg_hold * 10.0).round() / 10.0,
|
||||
median_hold_minutes: (median_hold * 10.0).round() / 10.0,
|
||||
buckets,
|
||||
correlation_with_profit: (correlation * 1000.0).round() / 1000.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn layer_performance(&self, deals: &[Deal]) -> Vec<LayerPerformance> {
|
||||
let mut layer_stats: HashMap<i32, (i32, i32, f64, f64)> = HashMap::new();
|
||||
|
||||
for deal in deals {
|
||||
let entry = deal.entry.to_lowercase();
|
||||
if entry.contains("out") && deal.profit != 0.0 {
|
||||
let layer = self.extract_layer(&deal.comment);
|
||||
let stats = layer_stats.entry(layer).or_insert((0, 0, 0.0, 0.0));
|
||||
stats.0 += 1;
|
||||
stats.2 += deal.profit;
|
||||
stats.3 += deal.volume;
|
||||
if deal.profit > 0.0 {
|
||||
stats.1 += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result: Vec<LayerPerformance> = layer_stats
|
||||
.into_iter()
|
||||
.map(|(layer, (trades, wins, total_pnl, total_volume))| LayerPerformance {
|
||||
layer,
|
||||
trades,
|
||||
wins,
|
||||
total_pnl: (total_pnl * 100.0).round() / 100.0,
|
||||
win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 },
|
||||
avg_volume: if trades > 0 { (total_volume / trades as f64 * 10000.0).round() / 10000.0 } else { 0.0 },
|
||||
avg_profit: if trades > 0 { (total_pnl / trades as f64 * 100.0).round() / 100.0 } else { 0.0 },
|
||||
})
|
||||
.collect();
|
||||
|
||||
result.sort_by_key(|l| l.layer);
|
||||
result
|
||||
}
|
||||
|
||||
pub fn volume_analysis(&self, deals: &[Deal]) -> VolumeAnalysis {
|
||||
let closed: Vec<&Deal> = deals
|
||||
.iter()
|
||||
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
|
||||
.collect();
|
||||
|
||||
if closed.is_empty() {
|
||||
return VolumeAnalysis {
|
||||
correlation_with_profit: 0.0,
|
||||
by_volume_bucket: vec![],
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate correlation
|
||||
let n = closed.len() as f64;
|
||||
let sum_x: f64 = closed.iter().map(|d| d.volume).sum();
|
||||
let sum_y: f64 = closed.iter().map(|d| d.profit).sum();
|
||||
let sum_xy: f64 = closed.iter().map(|d| d.volume * d.profit).sum();
|
||||
let sum_x2: f64 = closed.iter().map(|d| d.volume * d.volume).sum();
|
||||
let sum_y2: f64 = closed.iter().map(|d| d.profit * d.profit).sum();
|
||||
|
||||
let correlation = if n > 1.0 {
|
||||
let numerator = n * sum_xy - sum_x * sum_y;
|
||||
let denominator = ((n * sum_x2 - sum_x * sum_x) * (n * sum_y2 - sum_y * sum_y)).sqrt();
|
||||
if denominator > 0.0 { numerator / denominator } else { 0.0 }
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Create volume buckets
|
||||
let bucket_defs = [
|
||||
(0.0, 0.1, "0.0-0.1 lots"),
|
||||
(0.1, 0.5, "0.1-0.5 lots"),
|
||||
(0.5, 1.0, "0.5-1.0 lots"),
|
||||
(1.0, 2.0, "1.0-2.0 lots"),
|
||||
(2.0, 5.0, "2.0-5.0 lots"),
|
||||
(5.0, 999.0, "5.0+ lots"),
|
||||
];
|
||||
|
||||
let by_volume_bucket: Vec<VolumeBucket> = bucket_defs
|
||||
.iter()
|
||||
.map(|(min, max, range)| {
|
||||
let bucket_deals: Vec<&Deal> = closed
|
||||
.iter()
|
||||
.filter(|d| d.volume >= *min && d.volume < *max)
|
||||
.cloned()
|
||||
.collect();
|
||||
let trades = bucket_deals.len() as i32;
|
||||
let total_pnl: f64 = bucket_deals.iter().map(|d| d.profit).sum();
|
||||
let avg_profit = if trades > 0 { total_pnl / trades as f64 } else { 0.0 };
|
||||
|
||||
VolumeBucket {
|
||||
volume_range: range.to_string(),
|
||||
min_volume: *min,
|
||||
max_volume: *max,
|
||||
trades,
|
||||
avg_profit: (avg_profit * 100.0).round() / 100.0,
|
||||
total_pnl: (total_pnl * 100.0).round() / 100.0,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
VolumeAnalysis {
|
||||
correlation_with_profit: (correlation * 1000.0).round() / 1000.0,
|
||||
by_volume_bucket,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cost_analysis(&self, deals: &[Deal]) -> CostAnalysis {
|
||||
let total_commission: f64 = deals.iter().map(|d| d.commission.abs()).sum();
|
||||
let total_swap: f64 = deals.iter().map(|d| d.swap.abs()).sum();
|
||||
let gross_profit: f64 = deals.iter().map(|d| d.profit).filter(|p| *p > 0.0).sum();
|
||||
let trade_count = deals.iter().filter(|d| d.entry.to_lowercase().contains("out")).count() as f64;
|
||||
|
||||
let commission_pct = if gross_profit > 0.0 { (total_commission / gross_profit * 10000.0).round() / 100.0 } else { 0.0 };
|
||||
let swap_pct = if gross_profit > 0.0 { (total_swap / gross_profit * 10000.0).round() / 100.0 } else { 0.0 };
|
||||
|
||||
// Calculate what win rate would be without costs
|
||||
let wins_before_costs = deals
|
||||
.iter()
|
||||
.filter(|d| {
|
||||
let profit_before_costs = d.profit + d.commission.abs() + d.swap.abs();
|
||||
d.entry.to_lowercase().contains("out") && profit_before_costs > 0.0
|
||||
})
|
||||
.count() as f64;
|
||||
let total_closed = deals.iter().filter(|d| d.entry.to_lowercase().contains("out")).count() as f64;
|
||||
let win_rate_before_costs = if total_closed > 0.0 { wins_before_costs / total_closed * 100.0 } else { 0.0 };
|
||||
|
||||
let current_wins = deals.iter().filter(|d| d.entry.to_lowercase().contains("out") && d.profit > 0.0).count() as f64;
|
||||
let current_win_rate = if total_closed > 0.0 { current_wins / total_closed * 100.0 } else { 0.0 };
|
||||
|
||||
CostAnalysis {
|
||||
total_commission: (total_commission * 100.0).round() / 100.0,
|
||||
total_swap: (total_swap * 100.0).round() / 100.0,
|
||||
commission_pct_of_profit: commission_pct,
|
||||
swap_pct_of_profit: swap_pct,
|
||||
avg_commission_per_trade: if trade_count > 0.0 { (total_commission / trade_count * 100.0).round() / 100.0 } else { 0.0 },
|
||||
avg_swap_per_trade: if trade_count > 0.0 { (total_swap / trade_count * 100.0).round() / 100.0 } else { 0.0 },
|
||||
net_profit_before_costs: (gross_profit * 100.0).round() / 100.0,
|
||||
cost_impact_on_win_rate: (win_rate_before_costs - current_win_rate * 100.0).round() / 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn efficiency_analysis(&self, deals: &[Deal], _metrics: &Metrics) -> EfficiencyAnalysis {
|
||||
let closed: Vec<&Deal> = deals
|
||||
.iter()
|
||||
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
|
||||
.collect();
|
||||
|
||||
if closed.is_empty() {
|
||||
return EfficiencyAnalysis {
|
||||
profit_per_hour: 0.0,
|
||||
profit_per_day: 0.0,
|
||||
profit_per_trade_hour: 0.0,
|
||||
avg_trade_duration_hours: 0.0,
|
||||
annualized_return_pct: 0.0,
|
||||
trades_per_day: 0.0,
|
||||
};
|
||||
}
|
||||
|
||||
let total_profit: f64 = closed.iter().map(|d| d.profit).sum();
|
||||
let total_trades = closed.len() as f64;
|
||||
|
||||
// Calculate total hold time
|
||||
let mut total_hold_minutes = 0.0;
|
||||
let mut open_pos: HashMap<String, DateTime<chrono::Utc>> = HashMap::new();
|
||||
|
||||
for deal in deals {
|
||||
let entry = deal.entry.to_lowercase();
|
||||
if let Some(dt) = Self::parse_datetime(&deal.time) {
|
||||
if entry.contains("in") && !entry.contains("out") {
|
||||
open_pos.insert(deal.order.clone(), dt);
|
||||
} else if entry.contains("out") && deal.profit != 0.0 {
|
||||
if let Some(in_time) = open_pos.remove(&deal.order) {
|
||||
let hold_minutes = (dt - in_time).num_seconds() as f64 / 60.0;
|
||||
if hold_minutes > 0.0 {
|
||||
total_hold_minutes += hold_minutes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let total_hold_hours = total_hold_minutes / 60.0;
|
||||
let avg_trade_duration = if total_trades > 0.0 { total_hold_minutes / total_trades / 60.0 } else { 0.0 };
|
||||
|
||||
// Get date range
|
||||
let dates: Vec<DateTime<chrono::Utc>> = deals
|
||||
.iter()
|
||||
.filter_map(|d| Self::parse_datetime(&d.time))
|
||||
.collect();
|
||||
|
||||
let total_days = if dates.len() >= 2 {
|
||||
let min_date = dates.iter().min().unwrap();
|
||||
let max_date = dates.iter().max().unwrap();
|
||||
(*max_date - *min_date).num_days().max(1) as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
// Use a default deposit of 10000 for annualized calculation
|
||||
// In real scenarios, this should come from the report
|
||||
let deposit = 10000.0;
|
||||
let annualized = if total_days > 0.0 && deposit > 0.0 {
|
||||
let daily_return = total_profit / deposit / total_days;
|
||||
((1.0 + daily_return).powf(365.0) - 1.0) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
EfficiencyAnalysis {
|
||||
profit_per_hour: if total_hold_hours > 0.0 { (total_profit / total_hold_hours * 100.0).round() / 100.0 } else { 0.0 },
|
||||
profit_per_day: (total_profit / total_days * 100.0).round() / 100.0,
|
||||
profit_per_trade_hour: if total_hold_hours > 0.0 { (total_profit / total_hold_hours / total_trades * 100.0).round() / 100.0 } else { 0.0 },
|
||||
avg_trade_duration_hours: (avg_trade_duration * 10.0).round() / 10.0,
|
||||
annualized_return_pct: (annualized * 10.0).round() / 10.0,
|
||||
trades_per_day: (total_trades / total_days * 10.0).round() / 10.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
+16
-1
@@ -40,8 +40,11 @@ pub struct McpRequest {
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
pub struct McpResponse {
|
||||
jsonrpc: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<McpError>,
|
||||
}
|
||||
|
||||
@@ -49,6 +52,7 @@ pub struct McpResponse {
|
||||
pub struct McpError {
|
||||
code: i32,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
data: Option<Value>,
|
||||
}
|
||||
|
||||
@@ -78,7 +82,9 @@ pub struct ToolCapabilities {
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
@@ -111,6 +117,11 @@ async fn run_stdio_server() -> Result<()> {
|
||||
let server_clone = server.clone();
|
||||
match serde_json::from_str::<McpRequest>(line) {
|
||||
Ok(request) => {
|
||||
// Notifications have no id — don't send a response
|
||||
if request.id.is_none() {
|
||||
server_clone.handle_notification(request).await;
|
||||
continue;
|
||||
}
|
||||
let response = server_clone.handle_request(request).await;
|
||||
let response_json = serde_json::to_string(&response)?;
|
||||
println!("{}", response_json);
|
||||
@@ -180,6 +191,10 @@ async fn handle_connection(socket: tokio::net::TcpStream) -> Result<()> {
|
||||
|
||||
match serde_json::from_str::<McpRequest>(line) {
|
||||
Ok(request) => {
|
||||
if request.id.is_none() {
|
||||
server.handle_notification(request).await;
|
||||
continue;
|
||||
}
|
||||
let response = server.handle_request(request).await;
|
||||
let response_json = serde_json::to_string(&response)? + "\n";
|
||||
writer.write_all(response_json.as_bytes()).await?;
|
||||
|
||||
+14
-3
@@ -99,6 +99,18 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a notification (no id — no response sent)
|
||||
pub async fn handle_notification(&self, request: McpRequest) {
|
||||
match request.method.as_str() {
|
||||
"notifications/initialized" => {
|
||||
// Client confirms initialization is complete — no action needed
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("Unhandled notification: {}", request.method);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_request(&self, request: McpRequest) -> McpResponse {
|
||||
match request.method.as_str() {
|
||||
"initialize" => {
|
||||
@@ -123,9 +135,8 @@ impl McpServer {
|
||||
// Return immediately with fast status
|
||||
let server_info = json!({
|
||||
"name": "MT5-Quant",
|
||||
"version": "1.27.0",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"setup": {
|
||||
"verified": null, // null = checking
|
||||
"hint": "Auto-verification running... Use verify_setup tool for detailed status",
|
||||
}
|
||||
});
|
||||
@@ -164,7 +175,7 @@ impl McpServer {
|
||||
McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: request.id,
|
||||
result: Some(crate::tools::get_tools_list()),
|
||||
result: Some(json!({"tools": crate::tools::get_tools_list()})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+107
-7
@@ -8,7 +8,7 @@ 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};
|
||||
|
||||
pub struct BacktestPipeline {
|
||||
@@ -73,7 +73,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 +90,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,7 +115,7 @@ 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(
|
||||
@@ -122,14 +129,105 @@ impl BacktestPipeline {
|
||||
)
|
||||
.await;
|
||||
|
||||
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 config files
|
||||
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);
|
||||
}
|
||||
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
@@ -232,7 +330,7 @@ impl BacktestPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
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 +350,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!(
|
||||
@@ -761,7 +860,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 +880,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)?;
|
||||
|
||||
@@ -374,4 +374,543 @@ impl ReportDb {
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Get a specific report by ID
|
||||
pub fn get_by_id(&self, id: &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 id = ?"
|
||||
)?;
|
||||
|
||||
let entry = stmt
|
||||
.query_map([id], |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)
|
||||
}
|
||||
|
||||
/// Search reports by tags (at least one tag must match)
|
||||
pub fn search_by_tags(&self, tags: &[String], limit: usize) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE 1=1"
|
||||
.to_string();
|
||||
|
||||
// Build OR conditions for tags - use JSON1 extension for tag matching
|
||||
if !tags.is_empty() {
|
||||
let tag_conditions: Vec<String> = tags.iter()
|
||||
.map(|tag| format!("tags LIKE '%{}%'", tag.replace("'", "''")))
|
||||
.collect();
|
||||
sql.push_str(&format!(" AND ({})", tag_conditions.join(" OR ")));
|
||||
}
|
||||
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
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())
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Search reports by notes text (case-insensitive LIKE)
|
||||
pub fn search_by_notes(&self, query: &str, limit: usize) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let pattern = format!("%{}%", query.replace("'", "''"));
|
||||
let mut stmt = conn.prepare(
|
||||
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE notes LIKE '{}' ORDER BY created_at DESC LIMIT {}",
|
||||
pattern, limit)
|
||||
)?;
|
||||
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
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())
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Find reports by set file (original or snapshot)
|
||||
pub fn search_by_set_file(&self, set_file: &str, limit: usize) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let pattern = format!("%{}%", set_file.replace("'", "''"));
|
||||
let mut stmt = conn.prepare(
|
||||
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE (set_file_original LIKE '{}' OR set_snapshot_path LIKE '{}') \
|
||||
ORDER BY created_at DESC LIMIT {}",
|
||||
pattern, pattern, limit)
|
||||
)?;
|
||||
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
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())
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Search by backtest date range (from_date and to_date fields)
|
||||
pub fn search_by_date_range(
|
||||
&self,
|
||||
from_start: Option<&str>,
|
||||
from_end: Option<&str>,
|
||||
to_start: Option<&str>,
|
||||
to_end: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE 1=1"
|
||||
.to_string();
|
||||
|
||||
let mut params: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(start) = from_start {
|
||||
sql.push_str(" AND from_date >= ?");
|
||||
params.push(start.to_string());
|
||||
}
|
||||
if let Some(end) = from_end {
|
||||
sql.push_str(" AND from_date <= ?");
|
||||
params.push(end.to_string());
|
||||
}
|
||||
if let Some(start) = to_start {
|
||||
sql.push_str(" AND to_date >= ?");
|
||||
params.push(start.to_string());
|
||||
}
|
||||
if let Some(end) = to_end {
|
||||
sql.push_str(" AND to_date <= ?");
|
||||
params.push(end.to_string());
|
||||
}
|
||||
|
||||
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
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())
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Get comparable reports (same expert/symbol/timeframe for comparison)
|
||||
pub fn get_comparable(
|
||||
&self,
|
||||
expert: &str,
|
||||
symbol: &str,
|
||||
timeframe: &str,
|
||||
exclude_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE expert = ? AND symbol = ? AND timeframe = ?"
|
||||
.to_string();
|
||||
|
||||
let mut params: Vec<String> = vec![
|
||||
expert.to_string(),
|
||||
symbol.to_string(),
|
||||
timeframe.to_string(),
|
||||
];
|
||||
|
||||
if let Some(id) = exclude_id {
|
||||
sql.push_str(" AND id != ?");
|
||||
params.push(id.to_string());
|
||||
}
|
||||
|
||||
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
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())
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Get reports sorted by a specific metric (for best/worst queries)
|
||||
pub fn get_sorted_by(
|
||||
&self,
|
||||
sort_column: &str,
|
||||
ascending: bool,
|
||||
limit: usize,
|
||||
filters: &ReportFilters,
|
||||
) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
|
||||
// Validate sort column to prevent SQL injection
|
||||
let valid_columns = ["net_profit", "profit_factor", "max_dd_pct", "win_rate_pct",
|
||||
"sharpe_ratio", "recovery_factor", "total_trades", "created_at"];
|
||||
if !valid_columns.contains(&sort_column) {
|
||||
return Err(anyhow::anyhow!("Invalid sort column: {}", sort_column));
|
||||
}
|
||||
|
||||
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE 1=1"
|
||||
.to_string();
|
||||
|
||||
let mut filter_params: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(ea) = &filters.expert {
|
||||
sql.push_str(" AND expert LIKE ?");
|
||||
filter_params.push(format!("%{}%", ea));
|
||||
}
|
||||
if let Some(sym) = &filters.symbol {
|
||||
sql.push_str(" AND symbol = ?");
|
||||
filter_params.push(sym.clone());
|
||||
}
|
||||
if let Some(tf) = &filters.timeframe {
|
||||
sql.push_str(" AND timeframe = ?");
|
||||
filter_params.push(tf.clone());
|
||||
}
|
||||
if let Some(verdict) = &filters.verdict {
|
||||
sql.push_str(" AND verdict = ?");
|
||||
filter_params.push(verdict.clone());
|
||||
}
|
||||
|
||||
// For non-created_at columns, only include rows where that column is not NULL
|
||||
if sort_column != "created_at" {
|
||||
sql.push_str(&format!(" AND {} IS NOT NULL", sort_column));
|
||||
}
|
||||
|
||||
let order = if ascending { "ASC" } else { "DESC" };
|
||||
sql.push_str(&format!(" ORDER BY {} {} LIMIT {}", sort_column, order, limit));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map(rusqlite::params_from_iter(filter_params.iter()), |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
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())
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Get aggregate statistics for reports
|
||||
pub fn get_stats(&self, filters: &ReportFilters) -> Result<ReportStats> {
|
||||
let conn = self.connect()?;
|
||||
|
||||
let mut sql = "SELECT \
|
||||
COUNT(*), \
|
||||
AVG(net_profit), \
|
||||
AVG(profit_factor), \
|
||||
AVG(max_dd_pct), \
|
||||
AVG(win_rate_pct), \
|
||||
AVG(sharpe_ratio), \
|
||||
SUM(CASE WHEN net_profit > 0 THEN 1 ELSE 0 END), \
|
||||
SUM(CASE WHEN verdict = 'pass' THEN 1 ELSE 0 END), \
|
||||
SUM(CASE WHEN verdict = 'fail' THEN 1 ELSE 0 END), \
|
||||
SUM(CASE WHEN verdict = 'marginal' THEN 1 ELSE 0 END) \
|
||||
FROM reports WHERE 1=1"
|
||||
.to_string();
|
||||
|
||||
let mut filter_params: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(ea) = &filters.expert {
|
||||
sql.push_str(" AND expert LIKE ?");
|
||||
filter_params.push(format!("%{}%", ea));
|
||||
}
|
||||
if let Some(sym) = &filters.symbol {
|
||||
sql.push_str(" AND symbol = ?");
|
||||
filter_params.push(sym.clone());
|
||||
}
|
||||
if let Some(tf) = &filters.timeframe {
|
||||
sql.push_str(" AND timeframe = ?");
|
||||
filter_params.push(tf.clone());
|
||||
}
|
||||
if let Some(verdict) = &filters.verdict {
|
||||
sql.push_str(" AND verdict = ?");
|
||||
filter_params.push(verdict.clone());
|
||||
}
|
||||
|
||||
let stats: ReportStats = conn.query_row(
|
||||
&sql,
|
||||
rusqlite::params_from_iter(filter_params.iter()),
|
||||
|row| {
|
||||
let total: i64 = row.get(0)?;
|
||||
let profitable: Option<i64> = row.get(6)?;
|
||||
let pass_count: Option<i64> = row.get(7)?;
|
||||
let fail_count: Option<i64> = row.get(8)?;
|
||||
let marginal_count: Option<i64> = row.get(9)?;
|
||||
|
||||
Ok(ReportStats {
|
||||
total_count: total as usize,
|
||||
avg_net_profit: row.get(1)?,
|
||||
avg_profit_factor: row.get(2)?,
|
||||
avg_max_dd_pct: row.get(3)?,
|
||||
avg_win_rate_pct: row.get(4)?,
|
||||
avg_sharpe_ratio: row.get(5)?,
|
||||
profitable_count: profitable.unwrap_or(0) as usize,
|
||||
pass_verdict_count: pass_count.unwrap_or(0) as usize,
|
||||
fail_verdict_count: fail_count.unwrap_or(0) as usize,
|
||||
marginal_verdict_count: marginal_count.unwrap_or(0) as usize,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct ReportStats {
|
||||
pub total_count: usize,
|
||||
pub avg_net_profit: Option<f64>,
|
||||
pub avg_profit_factor: Option<f64>,
|
||||
pub avg_max_dd_pct: Option<f64>,
|
||||
pub avg_win_rate_pct: Option<f64>,
|
||||
pub avg_sharpe_ratio: Option<f64>,
|
||||
pub profitable_count: usize,
|
||||
pub pass_verdict_count: usize,
|
||||
pub fail_verdict_count: usize,
|
||||
pub marginal_verdict_count: usize,
|
||||
}
|
||||
|
||||
@@ -126,3 +126,155 @@ pub fn tool_analyze_concurrent_peak() -> Value {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_list_deals() -> Value {
|
||||
json!({
|
||||
"name": "list_deals",
|
||||
"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" },
|
||||
"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" },
|
||||
"start_date": { "type": "string", "description": "Start date filter (YYYY.MM.DD)" },
|
||||
"end_date": { "type": "string", "description": "End date filter (YYYY.MM.DD)" },
|
||||
"min_volume": { "type": "number", "description": "Minimum volume/lots" },
|
||||
"max_volume": { "type": "number", "description": "Maximum volume/lots" },
|
||||
"limit": { "type": "integer", "default": 100, "description": "Max deals to return" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_deals_by_comment() -> Value {
|
||||
json!({
|
||||
"name": "search_deals_by_comment",
|
||||
"description": "Search deals by comment text (case-insensitive partial match)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir", "query"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" },
|
||||
"query": { "type": "string", "description": "Search text in comments" },
|
||||
"limit": { "type": "integer", "default": 50 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_deals_by_magic() -> Value {
|
||||
json!({
|
||||
"name": "search_deals_by_magic",
|
||||
"description": "Filter deals by magic number (EA identifier)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir", "magic"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" },
|
||||
"magic": { "type": "string", "description": "Magic number to filter by" },
|
||||
"limit": { "type": "integer", "default": 100 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_profit_distribution() -> Value {
|
||||
json!({
|
||||
"name": "analyze_profit_distribution",
|
||||
"description": "Analyze profit distribution - small/medium/large wins and losses with detailed buckets",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_time_performance() -> Value {
|
||||
json!({
|
||||
"name": "analyze_time_performance",
|
||||
"description": "Analyze performance by hour of day and day of week",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_hold_time_distribution() -> Value {
|
||||
json!({
|
||||
"name": "analyze_hold_time_distribution",
|
||||
"description": "Analyze hold time distribution and correlation with profit",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_layer_performance() -> Value {
|
||||
json!({
|
||||
"name": "analyze_layer_performance",
|
||||
"description": "Analyze performance by grid layer (extracted from deal comments)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_volume_vs_profit() -> Value {
|
||||
json!({
|
||||
"name": "analyze_volume_vs_profit",
|
||||
"description": "Analyze correlation between volume and profit, plus performance by volume bucket",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_costs() -> Value {
|
||||
json!({
|
||||
"name": "analyze_costs",
|
||||
"description": "Analyze commission and swap costs impact on profitability",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_efficiency() -> Value {
|
||||
json!({
|
||||
"name": "analyze_efficiency",
|
||||
"description": "Calculate efficiency metrics: profit per hour/day, annualized return, trade frequency",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,85 @@ 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" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 +96,8 @@ 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" }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -22,7 +25,7 @@ pub fn get_tools_list() -> Value {
|
||||
optimization::tool_get_optimization_status(),
|
||||
optimization::tool_get_optimization_results(),
|
||||
optimization::tool_list_jobs(),
|
||||
// Analytics (9 tools)
|
||||
// Analytics (19 tools)
|
||||
analytics::tool_analyze_report(),
|
||||
analytics::tool_analyze_monthly_pnl(),
|
||||
analytics::tool_analyze_drawdown_events(),
|
||||
@@ -32,6 +35,17 @@ pub fn get_tools_list() -> Value {
|
||||
analytics::tool_analyze_direction_bias(),
|
||||
analytics::tool_analyze_streaks(),
|
||||
analytics::tool_analyze_concurrent_peak(),
|
||||
// Deal query tools (10)
|
||||
analytics::tool_list_deals(),
|
||||
analytics::tool_search_deals_by_comment(),
|
||||
analytics::tool_search_deals_by_magic(),
|
||||
analytics::tool_analyze_profit_distribution(),
|
||||
analytics::tool_analyze_time_performance(),
|
||||
analytics::tool_analyze_hold_time_distribution(),
|
||||
analytics::tool_analyze_layer_performance(),
|
||||
analytics::tool_analyze_volume_vs_profit(),
|
||||
analytics::tool_analyze_costs(),
|
||||
analytics::tool_analyze_efficiency(),
|
||||
// Baseline
|
||||
baseline::tool_compare_baseline(),
|
||||
// Experts
|
||||
@@ -49,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(),
|
||||
@@ -58,6 +74,16 @@ pub fn get_tools_list() -> Value {
|
||||
utility::tool_check_mt5_status(),
|
||||
utility::tool_create_set_template(),
|
||||
utility::tool_export_report(),
|
||||
// Debugging/Diagnostics (10 tools)
|
||||
utility::tool_diagnose_wine(),
|
||||
utility::tool_get_mt5_logs(),
|
||||
utility::tool_search_mt5_errors(),
|
||||
utility::tool_check_mt5_process(),
|
||||
utility::tool_kill_mt5_process(),
|
||||
utility::tool_check_system_resources(),
|
||||
utility::tool_validate_mt5_config(),
|
||||
utility::tool_get_wine_prefix_info(),
|
||||
utility::tool_get_backtest_crash_info(),
|
||||
// Set Files
|
||||
setfiles::tool_read_set_file(),
|
||||
setfiles::tool_write_set_file(),
|
||||
@@ -67,7 +93,7 @@ pub fn get_tools_list() -> Value {
|
||||
setfiles::tool_describe_sweep(),
|
||||
setfiles::tool_list_set_files(),
|
||||
setfiles::tool_set_from_optimization(),
|
||||
// Reports (11 tools)
|
||||
// Reports (19 tools)
|
||||
reports::tool_list_reports(),
|
||||
reports::tool_search_reports(),
|
||||
reports::tool_get_latest_report(),
|
||||
@@ -78,6 +104,14 @@ pub fn get_tools_list() -> Value {
|
||||
reports::tool_get_history(),
|
||||
reports::tool_promote_to_baseline(),
|
||||
reports::tool_annotate_history(),
|
||||
reports::tool_get_report_by_id(),
|
||||
reports::tool_get_reports_summary(),
|
||||
reports::tool_get_best_reports(),
|
||||
reports::tool_search_reports_by_tags(),
|
||||
reports::tool_search_reports_by_date_range(),
|
||||
reports::tool_search_reports_by_notes(),
|
||||
reports::tool_get_reports_by_set_file(),
|
||||
reports::tool_get_comparable_reports(),
|
||||
];
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -148,3 +148,133 @@ pub fn tool_prune_reports() -> Value {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_report_by_id() -> Value {
|
||||
json!({
|
||||
"name": "get_report_by_id",
|
||||
"description": "Get a specific report by its ID with full details and optional equity chart",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": { "type": "string", "description": "Report ID (from list_reports or search_reports)" },
|
||||
"include_chart": { "type": "boolean", "description": "Include equity chart as base64 PNG (default: true)", "default": true }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_reports_summary() -> Value {
|
||||
json!({
|
||||
"name": "get_reports_summary",
|
||||
"description": "Get aggregate statistics across reports - counts, averages by EA/symbol/timeframe/verdict",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expert": { "type": "string", "description": "Filter by EA name substring" },
|
||||
"symbol": { "type": "string", "description": "Filter by exact symbol" },
|
||||
"timeframe": { "type": "string", "description": "Filter by exact timeframe" },
|
||||
"verdict": { "type": "string", "description": "Filter by verdict (pass/fail/marginal)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_best_reports() -> Value {
|
||||
json!({
|
||||
"name": "get_best_reports",
|
||||
"description": "Get top N reports sorted by performance metric (profit factor, win rate, drawdown, etc.)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sort_by": { "type": "string", "enum": ["net_profit", "profit_factor", "max_dd_pct", "win_rate_pct", "sharpe_ratio", "recovery_factor", "total_trades"], "default": "profit_factor", "description": "Metric to sort by" },
|
||||
"order": { "type": "string", "enum": ["asc", "desc"], "default": "desc", "description": "Sort order (use 'asc' for drawdown, 'desc' for profit)" },
|
||||
"limit": { "type": "integer", "default": 10, "description": "Number of reports to return" },
|
||||
"expert": { "type": "string", "description": "Filter by EA name substring" },
|
||||
"symbol": { "type": "string", "description": "Filter by exact symbol" },
|
||||
"timeframe": { "type": "string", "description": "Filter by exact timeframe" },
|
||||
"verdict": { "type": "string", "description": "Filter by verdict" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_reports_by_tags() -> Value {
|
||||
json!({
|
||||
"name": "search_reports_by_tags",
|
||||
"description": "Search reports by tags - at least one tag must match (OR logic)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["tags"],
|
||||
"properties": {
|
||||
"tags": { "type": "array", "items": { "type": "string" }, "description": "Tags to search for (e.g., ['production', 'verified'])" },
|
||||
"limit": { "type": "integer", "default": 50 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_reports_by_date_range() -> Value {
|
||||
json!({
|
||||
"name": "search_reports_by_date_range",
|
||||
"description": "Search reports by backtest date range (from_date and to_date fields)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"from_start": { "type": "string", "description": "From date >= this (YYYY.MM.DD)" },
|
||||
"from_end": { "type": "string", "description": "From date <= this (YYYY.MM.DD)" },
|
||||
"to_start": { "type": "string", "description": "To date >= this (YYYY.MM.DD)" },
|
||||
"to_end": { "type": "string", "description": "To date <= this (YYYY.MM.DD)" },
|
||||
"limit": { "type": "integer", "default": 50 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_reports_by_notes() -> Value {
|
||||
json!({
|
||||
"name": "search_reports_by_notes",
|
||||
"description": "Full-text search in report notes field (case-insensitive LIKE search)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["query"],
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Search text (partial match)" },
|
||||
"limit": { "type": "integer", "default": 50 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_reports_by_set_file() -> Value {
|
||||
json!({
|
||||
"name": "get_reports_by_set_file",
|
||||
"description": "Find all reports that used a specific .set parameter file",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["set_file"],
|
||||
"properties": {
|
||||
"set_file": { "type": "string", "description": "Set filename or partial path to match" },
|
||||
"limit": { "type": "integer", "default": 50 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_comparable_reports() -> Value {
|
||||
json!({
|
||||
"name": "get_comparable_reports",
|
||||
"description": "Find reports comparable to a given report (same EA, symbol, timeframe) - useful for before/after analysis",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_id": { "type": "string", "description": "Reference report ID (if provided, uses its expert/symbol/timeframe)" },
|
||||
"expert": { "type": "string", "description": "EA name (required if report_id not provided)" },
|
||||
"symbol": { "type": "string", "description": "Symbol (required if report_id not provided)" },
|
||||
"timeframe": { "type": "string", "description": "Timeframe (required if report_id not provided)" },
|
||||
"exclude_id": { "type": "string", "description": "Exclude this report ID from results" },
|
||||
"limit": { "type": "integer", "default": 20 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -165,3 +165,149 @@ pub fn tool_export_report() -> Value {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_diagnose_wine() -> Value {
|
||||
json!({
|
||||
"name": "diagnose_wine",
|
||||
"description": "Check Wine installation, version, and prefix health. Reports errors, warnings, and recent Wine errors.",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_mt5_logs() -> Value {
|
||||
json!({
|
||||
"name": "get_mt5_logs",
|
||||
"description": "Get MT5 terminal, tester, or MetaEditor logs with optional search filtering",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"log_type": {
|
||||
"type": "string",
|
||||
"enum": ["terminal", "tester", "metaeditor"],
|
||||
"default": "terminal",
|
||||
"description": "Type of log to retrieve"
|
||||
},
|
||||
"lines": {
|
||||
"type": "integer",
|
||||
"default": 100,
|
||||
"description": "Number of lines to return (from end of log)"
|
||||
},
|
||||
"search": {
|
||||
"type": "string",
|
||||
"description": "Optional search term to filter log lines"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_mt5_errors() -> Value {
|
||||
json!({
|
||||
"name": "search_mt5_errors",
|
||||
"description": "Search MT5 logs for error patterns (error, failed, crash, exception, etc.) in recent hours",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"hours_back": {
|
||||
"type": "integer",
|
||||
"default": 24,
|
||||
"description": "Hours to search back in logs"
|
||||
},
|
||||
"max_errors": {
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"description": "Maximum number of errors to return"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_check_mt5_process() -> Value {
|
||||
json!({
|
||||
"name": "check_mt5_process",
|
||||
"description": "Check if MT5 processes are running, get process info (PID, CPU, memory usage)",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_kill_mt5_process() -> Value {
|
||||
json!({
|
||||
"name": "kill_mt5_process",
|
||||
"description": "Kill stuck MT5 processes. Use force=true for stuck wineserver.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pid": {
|
||||
"type": "string",
|
||||
"description": "Optional specific PID to kill"
|
||||
},
|
||||
"force": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Use SIGKILL (-9) instead of SIGTERM (-15), also kills wineserver"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_check_system_resources() -> Value {
|
||||
json!({
|
||||
"name": "check_system_resources",
|
||||
"description": "Check disk space, memory, and CPU. Warns if resources are low for MT5 operations.",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_validate_mt5_config() -> Value {
|
||||
json!({
|
||||
"name": "validate_mt5_config",
|
||||
"description": "Validate MT5 configuration files (terminal.ini, tester settings). Reports errors and warnings.",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_wine_prefix_info() -> Value {
|
||||
json!({
|
||||
"name": "get_wine_prefix_info",
|
||||
"description": "Get detailed Wine prefix information: Windows version, installed programs, registry files, drive_c size",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_backtest_crash_info() -> Value {
|
||||
json!({
|
||||
"name": "get_backtest_crash_info",
|
||||
"description": "Investigate backtest crashes/failures. Checks for incomplete markers, missing deals.csv, error logs. Can scan recent reports.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": {
|
||||
"type": "string",
|
||||
"description": "Optional specific report directory to check"
|
||||
},
|
||||
"check_recent": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Also check recent reports for failures"
|
||||
},
|
||||
"hours_back": {
|
||||
"type": "integer",
|
||||
"default": 6,
|
||||
"description": "Hours back to check for recent failures"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+244
-205
@@ -6,8 +6,30 @@ use std::path::Path;
|
||||
use crate::analytics::DealAnalyzer;
|
||||
use crate::models::deals::Deal;
|
||||
use crate::models::metrics::Metrics;
|
||||
use crate::models::Config;
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
/// 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");
|
||||
@@ -17,10 +39,8 @@ fn load_report_data(report_dir: &str) -> Result<(Vec<Deal>, Metrics)> {
|
||||
}
|
||||
|
||||
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)?
|
||||
serde_json::from_str(&fs::read_to_string(&metrics_json)?)?
|
||||
} else {
|
||||
Metrics::default()
|
||||
};
|
||||
@@ -28,75 +48,18 @@ fn load_report_data(report_dir: &str) -> Result<(Vec<Deal>, Metrics)> {
|
||||
Ok((deals, metrics))
|
||||
}
|
||||
|
||||
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"))?;
|
||||
|
||||
fn prepare_analysis(report_dir: &str) -> Result<(Vec<Deal>, Metrics, DealAnalyzer)> {
|
||||
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());
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
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
|
||||
}))
|
||||
Ok((deals, metrics, DealAnalyzer::new()))
|
||||
}
|
||||
|
||||
fn read_deals_from_csv(path: &Path) -> Result<Vec<Deal>> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut deals = Vec::new();
|
||||
|
||||
|
||||
let mut lines = content.lines();
|
||||
let _header = lines.next();
|
||||
|
||||
|
||||
for line in lines {
|
||||
let parts: Vec<&str> = line.split(',').collect();
|
||||
if parts.len() >= 12 {
|
||||
@@ -118,192 +81,268 @@ fn read_deals_from_csv(path: &Path) -> Result<Vec<Deal>> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Ok(deals)
|
||||
}
|
||||
|
||||
// ── Composite analytics ───────────────────────────────────────────────────────
|
||||
|
||||
pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, metrics, analyzer) = prepare_analysis(report_dir)?;
|
||||
|
||||
let 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());
|
||||
|
||||
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);
|
||||
|
||||
let mut result = json!({});
|
||||
|
||||
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");
|
||||
fs::write(&analysis_path, serde_json::to_string_pretty(&result)?)?;
|
||||
|
||||
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 = required_str(args, "report_dir")?;
|
||||
|
||||
let baseline_path = Path::new("config/baseline.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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "monthly_pnl": analyzer.monthly_pnl(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_drawdown_events(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let 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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, metrics, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "drawdown_events": analyzer.reconstruct_dd_events(&deals, &metrics) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_top_losses(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let report_dir = required_str(args, "report_dir")?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
|
||||
let (deals, _) = 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(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "limit": limit, "top_losses": analyzer.top_losses(&deals, limit) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_loss_sequences(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let 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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "loss_sequences": analyzer.loss_sequences(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_position_pairs(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let 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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "position_pairs": analyzer.position_pairs(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_direction_bias(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let 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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "direction_bias": analyzer.direction_bias(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_streaks(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let 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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "streak_analysis": analyzer.streak_analysis(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_concurrent_peak(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let 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 report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "concurrent_peak": analyzer.concurrent_peak(&deals) })))
|
||||
}
|
||||
|
||||
// Import Config for analysis module
|
||||
use crate::models::Config;
|
||||
pub async fn handle_analyze_profit_distribution(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "profit_distribution": analyzer.profit_distribution(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_time_performance(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "time_performance": analyzer.time_performance(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_hold_time_distribution(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "hold_time_analysis": analyzer.hold_time_analysis(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_layer_performance(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "layer_performance": analyzer.layer_performance(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_volume_vs_profit(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "volume_analysis": analyzer.volume_analysis(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_costs(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, _, analyzer) = prepare_analysis(report_dir)?;
|
||||
Ok(ok_response(json!({ "success": true, "cost_analysis": analyzer.cost_analysis(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_efficiency(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = required_str(args, "report_dir")?;
|
||||
let (deals, metrics, analyzer) = prepare_analysis(report_dir)?;
|
||||
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 = required_str(args, "report_dir")?;
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
|
||||
let deal_type = args.get("deal_type").and_then(|v| v.as_str());
|
||||
let min_profit = args.get("min_profit").and_then(|v| v.as_f64());
|
||||
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 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| {
|
||||
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();
|
||||
|
||||
filtered.sort_by(|a, b| b.time.cmp(&a.time));
|
||||
filtered.truncate(limit);
|
||||
|
||||
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 = required_str(args, "report_dir")?;
|
||||
let query = required_str(args, "query")?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let query_lower = query.to_lowercase();
|
||||
|
||||
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);
|
||||
|
||||
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 = required_str(args, "report_dir")?;
|
||||
let magic = required_str(args, "magic")?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
|
||||
let 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);
|
||||
|
||||
Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"magic": magic,
|
||||
"matched": filtered.len(),
|
||||
"deals": filtered.iter().map(|d| deal_to_json(d)).collect::<Vec<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
// suppress unused warning — err_response is available for future handlers
|
||||
#[allow(dead_code)]
|
||||
fn _use_err_response() { let _ = err_response(""); }
|
||||
|
||||
+217
-10
@@ -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
|
||||
@@ -190,38 +192,243 @@ 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(config: &Config, args: &Value) -> Result<Value> {
|
||||
let expert = args.get("expert")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("expert is required"))?;
|
||||
|
||||
// Run pre-flight check
|
||||
let preflight = BacktestPreflight::check(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() {
|
||||
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::past_complete_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),
|
||||
};
|
||||
|
||||
let pipeline = BacktestPipeline::new(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,6 +2,8 @@ use anyhow::Result;
|
||||
use chrono::Datelike;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::Path;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use crate::models::Config;
|
||||
|
||||
mod system;
|
||||
@@ -13,6 +15,13 @@ mod setfiles;
|
||||
mod reports;
|
||||
mod utility;
|
||||
|
||||
/// Cached result of the background update check.
|
||||
/// - Not yet initialized: check still in flight (or not spawned yet)
|
||||
/// - Some(version): a newer version is available
|
||||
/// - None: already on latest, or GitHub unreachable
|
||||
pub(crate) static LATEST_VERSION: OnceLock<Option<String>> = OnceLock::new();
|
||||
static BACKGROUND_CHECK_SPAWNED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ToolHandler {
|
||||
pub config: Config,
|
||||
@@ -24,12 +33,22 @@ impl ToolHandler {
|
||||
}
|
||||
|
||||
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 +61,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.config, args).await, // Fire-and-forget mode
|
||||
"get_backtest_status" => backtest::handle_get_backtest_status(&self.config, args).await,
|
||||
"cache_status" => backtest::handle_cache_status(&self.config).await,
|
||||
"clean_cache" => backtest::handle_clean_cache(&self.config, args).await,
|
||||
@@ -65,6 +87,17 @@ impl ToolHandler {
|
||||
"analyze_streaks" => analysis::handle_analyze_streaks(&self.config, args).await,
|
||||
"analyze_concurrent_peak" => analysis::handle_analyze_concurrent_peak(&self.config, args).await,
|
||||
"compare_baseline" => analysis::handle_compare_baseline(&self.config, args).await,
|
||||
// Deal query handlers
|
||||
"list_deals" => analysis::handle_list_deals(&self.config, args).await,
|
||||
"search_deals_by_comment" => analysis::handle_search_deals_by_comment(&self.config, args).await,
|
||||
"search_deals_by_magic" => analysis::handle_search_deals_by_magic(&self.config, args).await,
|
||||
"analyze_profit_distribution" => analysis::handle_analyze_profit_distribution(&self.config, args).await,
|
||||
"analyze_time_performance" => analysis::handle_analyze_time_performance(&self.config, args).await,
|
||||
"analyze_hold_time_distribution" => analysis::handle_analyze_hold_time_distribution(&self.config, args).await,
|
||||
"analyze_layer_performance" => analysis::handle_analyze_layer_performance(&self.config, args).await,
|
||||
"analyze_volume_vs_profit" => analysis::handle_analyze_volume_vs_profit(&self.config, args).await,
|
||||
"analyze_costs" => analysis::handle_analyze_costs(&self.config, args).await,
|
||||
"analyze_efficiency" => analysis::handle_analyze_efficiency(&self.config, args).await,
|
||||
|
||||
// Set file handlers
|
||||
"read_set_file" => setfiles::handle_read_set_file(args).await,
|
||||
@@ -87,6 +120,14 @@ impl ToolHandler {
|
||||
"promote_to_baseline" => reports::handle_promote_to_baseline(&self.config, args).await,
|
||||
"get_history" => reports::handle_get_history(args).await,
|
||||
"annotate_history" => reports::handle_annotate_history(args).await,
|
||||
"get_report_by_id" => reports::handle_get_report_by_id(&self.config, args).await,
|
||||
"get_reports_summary" => reports::handle_get_reports_summary(args).await,
|
||||
"get_best_reports" => reports::handle_get_best_reports(args).await,
|
||||
"search_reports_by_tags" => reports::handle_search_reports_by_tags(args).await,
|
||||
"search_reports_by_date_range" => reports::handle_search_reports_by_date_range(args).await,
|
||||
"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,
|
||||
|
||||
// Utility handlers
|
||||
"check_symbol_data_status" => utility::handle_check_symbol_data_status(&self.config, args).await,
|
||||
@@ -97,7 +138,17 @@ impl ToolHandler {
|
||||
"check_mt5_status" => utility::handle_check_mt5_status(&self.config).await,
|
||||
"create_set_template" => utility::handle_create_set_template(&self.config, args).await,
|
||||
"export_report" => utility::handle_export_report(&self.config, args).await,
|
||||
|
||||
// Debugging/diagnostics handlers
|
||||
"diagnose_wine" => utility::handle_diagnose_wine(&self.config, args).await,
|
||||
"get_mt5_logs" => utility::handle_get_mt5_logs(&self.config, args).await,
|
||||
"search_mt5_errors" => utility::handle_search_mt5_errors(&self.config, args).await,
|
||||
"check_mt5_process" => utility::handle_check_mt5_process(&self.config, args).await,
|
||||
"kill_mt5_process" => utility::handle_kill_mt5_process(&self.config, args).await,
|
||||
"check_system_resources" => utility::handle_check_system_resources(&self.config, args).await,
|
||||
"validate_mt5_config" => utility::handle_validate_mt5_config(&self.config, args).await,
|
||||
"get_wine_prefix_info" => utility::handle_get_wine_prefix_info(&self.config, args).await,
|
||||
"get_backtest_crash_info" => utility::handle_get_backtest_crash_info(&self.config, args).await,
|
||||
|
||||
_ => Ok(json!({
|
||||
"content": [{ "type": "text", "text": format!("Tool '{}' not implemented", name) }],
|
||||
"isError": true
|
||||
|
||||
@@ -433,3 +433,418 @@ pub async fn handle_annotate_history(args: &Value) -> Result<Value> {
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_get_report_by_id(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let id = args.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("id is required"))?;
|
||||
let include_chart = args.get("include_chart").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
db.init()?;
|
||||
|
||||
match db.get_by_id(id)? {
|
||||
Some(entry) => {
|
||||
let mut response = json!({
|
||||
"success": true,
|
||||
"report": {
|
||||
"id": entry.id,
|
||||
"expert": entry.expert,
|
||||
"symbol": entry.symbol,
|
||||
"timeframe": entry.timeframe,
|
||||
"from_date": entry.from_date,
|
||||
"to_date": entry.to_date,
|
||||
"created_at": entry.created_at,
|
||||
"net_profit": entry.net_profit,
|
||||
"profit_factor": entry.profit_factor,
|
||||
"max_dd_pct": entry.max_dd_pct,
|
||||
"sharpe_ratio": entry.sharpe_ratio,
|
||||
"total_trades": entry.total_trades,
|
||||
"win_rate_pct": entry.win_rate_pct,
|
||||
"recovery_factor": entry.recovery_factor,
|
||||
"deposit": entry.deposit,
|
||||
"currency": entry.currency,
|
||||
"leverage": entry.leverage,
|
||||
"duration_seconds": entry.duration_seconds,
|
||||
"set_file_original": entry.set_file_original,
|
||||
"set_snapshot_path": entry.set_snapshot_path,
|
||||
"report_dir": entry.report_dir,
|
||||
"charts_dir": entry.charts_dir,
|
||||
"tags": entry.tags,
|
||||
"notes": entry.notes,
|
||||
"verdict": entry.verdict,
|
||||
}
|
||||
});
|
||||
|
||||
if include_chart {
|
||||
if let Some(charts_dir) = &entry.charts_dir {
|
||||
let chart_path = Path::new(charts_dir).join("equity.png");
|
||||
if chart_path.exists() {
|
||||
match fs::read(&chart_path) {
|
||||
Ok(bytes) => {
|
||||
let base64 = BASE64.encode(&bytes);
|
||||
response["report"]["equity_chart_base64"] = json!(base64);
|
||||
response["report"]["equity_chart_format"] = json!("png");
|
||||
}
|
||||
Err(e) => {
|
||||
response["report"]["equity_chart_error"] = json!(format!("Failed to read chart: {}", e));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response["report"]["equity_chart_error"] = json!("equity.png not found in charts_dir");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": response.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
None => {
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": false,
|
||||
"error": format!("Report with id '{}' not found", id)
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_get_reports_summary(args: &Value) -> Result<Value> {
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
db.init()?;
|
||||
|
||||
let filters = ReportFilters {
|
||||
expert: args.get("expert").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
symbol: args.get("symbol").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
timeframe: args.get("timeframe").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let stats = db.get_stats(&filters)?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"total_count": stats.total_count,
|
||||
"profitable_count": stats.profitable_count,
|
||||
"pass_verdict_count": stats.pass_verdict_count,
|
||||
"fail_verdict_count": stats.fail_verdict_count,
|
||||
"marginal_verdict_count": stats.marginal_verdict_count,
|
||||
"avg_net_profit": stats.avg_net_profit,
|
||||
"avg_profit_factor": stats.avg_profit_factor,
|
||||
"avg_max_dd_pct": stats.avg_max_dd_pct,
|
||||
"avg_win_rate_pct": stats.avg_win_rate_pct,
|
||||
"avg_sharpe_ratio": stats.avg_sharpe_ratio,
|
||||
"profitable_rate": if stats.total_count > 0 {
|
||||
(stats.profitable_count as f64 / stats.total_count as f64 * 100.0).round()
|
||||
} else { 0.0 },
|
||||
"pass_rate": if stats.total_count > 0 {
|
||||
(stats.pass_verdict_count as f64 / stats.total_count as f64 * 100.0).round()
|
||||
} else { 0.0 },
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_get_best_reports(args: &Value) -> Result<Value> {
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
db.init()?;
|
||||
|
||||
let sort_by = args.get("sort_by").and_then(|v| v.as_str()).unwrap_or("profit_factor");
|
||||
let order = args.get("order").and_then(|v| v.as_str()).unwrap_or("desc");
|
||||
let ascending = order == "asc";
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
|
||||
|
||||
let filters = ReportFilters {
|
||||
expert: args.get("expert").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
symbol: args.get("symbol").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
timeframe: args.get("timeframe").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let entries = db.get_sorted_by(sort_by, ascending, limit, &filters)?;
|
||||
|
||||
let reports: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"sharpe_ratio": e.sharpe_ratio,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"sort_by": sort_by,
|
||||
"order": order,
|
||||
"matched": reports.len(),
|
||||
"reports": reports,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_search_reports_by_tags(args: &Value) -> Result<Value> {
|
||||
let tags: Vec<String> = args
|
||||
.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
|
||||
.ok_or_else(|| anyhow::anyhow!("tags array is required"))?;
|
||||
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
db.init()?;
|
||||
|
||||
let entries = db.search_by_tags(&tags, limit)?;
|
||||
|
||||
let reports: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
"notes": e.notes,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"tags": tags,
|
||||
"matched": reports.len(),
|
||||
"reports": reports,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_search_reports_by_date_range(args: &Value) -> Result<Value> {
|
||||
let from_start = args.get("from_start").and_then(|v| v.as_str());
|
||||
let from_end = args.get("from_end").and_then(|v| v.as_str());
|
||||
let to_start = args.get("to_start").and_then(|v| v.as_str());
|
||||
let to_end = args.get("to_end").and_then(|v| v.as_str());
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
db.init()?;
|
||||
|
||||
let entries = db.search_by_date_range(from_start, from_end, to_start, to_end, limit)?;
|
||||
|
||||
let reports: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"from_start": from_start,
|
||||
"from_end": from_end,
|
||||
"to_start": to_start,
|
||||
"to_end": to_end,
|
||||
"matched": reports.len(),
|
||||
"reports": reports,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_search_reports_by_notes(args: &Value) -> Result<Value> {
|
||||
let query = args.get("query")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("query is required"))?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
db.init()?;
|
||||
|
||||
let entries = db.search_by_notes(query, limit)?;
|
||||
|
||||
let reports: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"verdict": e.verdict,
|
||||
"notes": e.notes,
|
||||
"tags": e.tags,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"query": query,
|
||||
"matched": reports.len(),
|
||||
"reports": reports,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_get_reports_by_set_file(args: &Value) -> Result<Value> {
|
||||
let set_file = args.get("set_file")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("set_file is required"))?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
db.init()?;
|
||||
|
||||
let entries = db.search_by_set_file(set_file, limit)?;
|
||||
|
||||
let reports: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file_original": e.set_file_original,
|
||||
"set_snapshot_path": e.set_snapshot_path,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"set_file": set_file,
|
||||
"matched": reports.len(),
|
||||
"reports": reports,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_get_comparable_reports(args: &Value) -> Result<Value> {
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
db.init()?;
|
||||
|
||||
// Get expert/symbol/timeframe either from report_id or direct args
|
||||
let (expert, symbol, timeframe, exclude_id) = if let Some(id) = args.get("report_id").and_then(|v| v.as_str()) {
|
||||
match db.get_by_id(id)? {
|
||||
Some(entry) => {
|
||||
let exclude = args.get("exclude_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
(entry.expert, entry.symbol, entry.timeframe, exclude.unwrap_or_else(|| id.to_string()))
|
||||
}
|
||||
None => return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": false,
|
||||
"error": format!("Report with id '{}' not found", id)
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
let expert = args.get("expert").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("expert or report_id is required"))?;
|
||||
let symbol = args.get("symbol").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("symbol or report_id is required"))?;
|
||||
let timeframe = args.get("timeframe").and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("timeframe or report_id is required"))?;
|
||||
let exclude_id = args.get("exclude_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
(expert.to_string(), symbol.to_string(), timeframe.to_string(), exclude_id.unwrap_or_default())
|
||||
};
|
||||
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
|
||||
let exclude_opt = if exclude_id.is_empty() { None } else { Some(exclude_id.as_str()) };
|
||||
|
||||
let entries = db.get_comparable(&expert, &symbol, &timeframe, exclude_opt, limit)?;
|
||||
|
||||
let reports: Vec<Value> = entries
|
||||
.iter()
|
||||
.map(|e| json!({
|
||||
"id": e.id,
|
||||
"expert": e.expert,
|
||||
"symbol": e.symbol,
|
||||
"timeframe": e.timeframe,
|
||||
"from_date": e.from_date,
|
||||
"to_date": e.to_date,
|
||||
"created_at": e.created_at,
|
||||
"net_profit": e.net_profit,
|
||||
"profit_factor": e.profit_factor,
|
||||
"max_dd_pct": e.max_dd_pct,
|
||||
"total_trades": e.total_trades,
|
||||
"win_rate_pct": e.win_rate_pct,
|
||||
"set_file": e.set_file_original,
|
||||
"verdict": e.verdict,
|
||||
"tags": e.tags,
|
||||
}))
|
||||
.collect();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"expert": expert,
|
||||
"symbol": symbol,
|
||||
"timeframe": timeframe,
|
||||
"exclude_id": exclude_id,
|
||||
"matched": reports.len(),
|
||||
"reports": reports,
|
||||
}).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;
|
||||
|
||||
@@ -841,3 +841,878 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result<Valu
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
// === Wine & MT5 Debugging Tools ===
|
||||
|
||||
/// Diagnose Wine installation and prefix health
|
||||
pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Value> {
|
||||
let mut diagnostics = json!({
|
||||
"wine_executable": null,
|
||||
"wine_version": null,
|
||||
"wine_prefix": null,
|
||||
"prefix_health": null,
|
||||
"prefix_exists": false,
|
||||
"prefix_size_mb": 0,
|
||||
"errors": Vec::<String>::new(),
|
||||
"warnings": Vec::<String>::new(),
|
||||
});
|
||||
|
||||
// Check wine executable
|
||||
if let Some(wine_exe) = config.wine_executable.as_ref() {
|
||||
diagnostics["wine_executable"] = json!(wine_exe);
|
||||
|
||||
// Get Wine version
|
||||
let version_output = std::process::Command::new(wine_exe)
|
||||
.arg("--version")
|
||||
.output();
|
||||
|
||||
match version_output {
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
diagnostics["wine_version"] = json!(version);
|
||||
}
|
||||
_ => {
|
||||
diagnostics["errors"].as_array_mut().unwrap().push(
|
||||
json!("Failed to get Wine version - Wine may not be properly installed")
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
diagnostics["errors"].as_array_mut().unwrap().push(
|
||||
json!("Wine executable not configured")
|
||||
);
|
||||
}
|
||||
|
||||
// Check Wine prefix
|
||||
if let Some(mt5_dir) = config.mt5_dir() {
|
||||
let wine_prefix = mt5_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent());
|
||||
|
||||
if let Some(prefix) = wine_prefix {
|
||||
diagnostics["wine_prefix"] = json!(prefix.to_string_lossy().to_string());
|
||||
|
||||
// Check if prefix exists
|
||||
let prefix_exists = prefix.exists();
|
||||
diagnostics["prefix_exists"] = json!(prefix_exists);
|
||||
|
||||
if prefix_exists {
|
||||
// Calculate prefix size
|
||||
let mut total_size = 0u64;
|
||||
fn calculate_size(dir: &Path, total: &mut u64) {
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
*total += meta.len();
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
calculate_size(&path, total);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
calculate_size(prefix, &mut total_size);
|
||||
diagnostics["prefix_size_mb"] = json!((total_size / 1024 / 1024) as i64);
|
||||
|
||||
// Check critical directories
|
||||
let system32 = prefix.join("drive_c/windows/system32");
|
||||
let program_files = prefix.join("drive_c/Program Files");
|
||||
|
||||
if !system32.exists() {
|
||||
diagnostics["errors"].as_array_mut().unwrap().push(
|
||||
json!("Wine prefix missing system32 directory - prefix may be corrupted")
|
||||
);
|
||||
diagnostics["prefix_health"] = json!("corrupted");
|
||||
} else if !program_files.exists() {
|
||||
diagnostics["warnings"].as_array_mut().unwrap().push(
|
||||
json!("Program Files directory not found")
|
||||
);
|
||||
diagnostics["prefix_health"] = json!("incomplete");
|
||||
} else {
|
||||
diagnostics["prefix_health"] = json!("healthy");
|
||||
}
|
||||
|
||||
// Check for recent Wine errors
|
||||
let wine_log = prefix.join("wine.log");
|
||||
if wine_log.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&wine_log) {
|
||||
let recent_errors: Vec<&str> = content.lines()
|
||||
.filter(|l| l.contains("err:") || l.contains("fixme:"))
|
||||
.rev()
|
||||
.take(10)
|
||||
.collect();
|
||||
if !recent_errors.is_empty() {
|
||||
diagnostics["recent_wine_errors"] = json!(recent_errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
diagnostics["errors"].as_array_mut().unwrap().push(
|
||||
json!("Wine prefix directory does not exist")
|
||||
);
|
||||
diagnostics["prefix_health"] = json!("missing");
|
||||
}
|
||||
} else {
|
||||
diagnostics["errors"].as_array_mut().unwrap().push(
|
||||
json!("Could not determine Wine prefix from MT5 directory")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
diagnostics["errors"].as_array_mut().unwrap().push(
|
||||
json!("MT5 directory not configured")
|
||||
);
|
||||
}
|
||||
|
||||
let has_errors = !diagnostics["errors"].as_array().unwrap().is_empty();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": diagnostics.to_string() }],
|
||||
"isError": has_errors
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get MT5 terminal logs
|
||||
pub async fn handle_get_mt5_logs(config: &Config, args: &Value) -> Result<Value> {
|
||||
let log_type = args.get("log_type").and_then(|v| v.as_str()).unwrap_or("terminal");
|
||||
let lines = args.get("lines").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
|
||||
let search = args.get("search").and_then(|v| v.as_str());
|
||||
|
||||
let mt5_dir = config.mt5_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
|
||||
|
||||
let log_path = match log_type {
|
||||
"terminal" => mt5_dir.join("logs").join(format!("{}", chrono::Local::now().format("%Y%m%d"))),
|
||||
"tester" => mt5_dir.join("Tester").join("logs"),
|
||||
"metaeditor" => mt5_dir.join("MetaEditor").join("logs"),
|
||||
_ => mt5_dir.join("logs"),
|
||||
};
|
||||
|
||||
let mut result = json!({
|
||||
"log_type": log_type,
|
||||
"log_path": log_path.to_string_lossy().to_string(),
|
||||
"found": false,
|
||||
"lines_total": 0,
|
||||
"lines_returned": 0,
|
||||
"content": Vec::<String>::new(),
|
||||
});
|
||||
|
||||
// Find log files
|
||||
let mut log_files: Vec<_> = Vec::new();
|
||||
if log_path.exists() {
|
||||
if let Ok(entries) = fs::read_dir(&log_path) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Some(ext) = path.extension() {
|
||||
if ext == "log" {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
log_files.push((path, modified));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by modification time (newest first)
|
||||
log_files.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
if let Some((latest_log, _)) = log_files.first() {
|
||||
result["found"] = json!(true);
|
||||
|
||||
if let Ok(content) = fs::read_to_string(latest_log) {
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
result["lines_total"] = json!(all_lines.len());
|
||||
|
||||
// Filter and limit lines
|
||||
let mut filtered: Vec<&str> = all_lines.clone();
|
||||
|
||||
// Apply search filter
|
||||
if let Some(search_term) = search {
|
||||
let search_lower = search_term.to_lowercase();
|
||||
filtered.retain(|line| line.to_lowercase().contains(&search_lower));
|
||||
}
|
||||
|
||||
// Get last N lines
|
||||
let start = filtered.len().saturating_sub(lines);
|
||||
let final_lines: Vec<String> = filtered[start..].iter().map(|s| s.to_string()).collect();
|
||||
|
||||
result["lines_returned"] = json!(final_lines.len());
|
||||
result["content"] = json!(final_lines);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Search MT5 logs for error patterns
|
||||
pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result<Value> {
|
||||
let error_patterns = vec![
|
||||
"error", "failed", "crash", "exception", "access violation",
|
||||
"out of memory", "cannot", "unable to", "terminated"
|
||||
];
|
||||
let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(24);
|
||||
let max_errors = args.get("max_errors").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
|
||||
let mt5_dir = config.mt5_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
|
||||
|
||||
let mut errors_found = Vec::new();
|
||||
let logs_dir = mt5_dir.join("logs");
|
||||
let cutoff_time = std::time::SystemTime::now() - std::time::Duration::from_secs(hours_back * 3600);
|
||||
|
||||
// Search recent log files
|
||||
if logs_dir.exists() {
|
||||
if let Ok(entries) = fs::read_dir(&logs_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "log").unwrap_or(false) {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if modified >= cutoff_time {
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
let line_lower = line.to_lowercase();
|
||||
for pattern in &error_patterns {
|
||||
if line_lower.contains(pattern) {
|
||||
errors_found.push(json!({
|
||||
"file": path.file_name().unwrap_or_default().to_string_lossy().to_string(),
|
||||
"line": i + 1,
|
||||
"content": line.trim().to_string(),
|
||||
"pattern": pattern,
|
||||
}));
|
||||
if errors_found.len() >= max_errors {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors_found.len() >= max_errors {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = json!({
|
||||
"hours_searched": hours_back,
|
||||
"errors_found": errors_found.len(),
|
||||
"max_errors": max_errors,
|
||||
"errors": errors_found,
|
||||
"suggestion": if errors_found.is_empty() {
|
||||
"No errors found in recent logs. Check get_mt5_logs for full log content."
|
||||
} else {
|
||||
"Found potential errors. Review the 'content' field for details."
|
||||
},
|
||||
});
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check MT5 process status
|
||||
pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result<Value> {
|
||||
use std::process::Command;
|
||||
|
||||
let mut result = json!({
|
||||
"is_running": false,
|
||||
"processes": Vec::<serde_json::Value>::new(),
|
||||
"wine_server_running": false,
|
||||
"total_instances": 0,
|
||||
});
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Check for MT5 processes
|
||||
let ps_output = Command::new("ps")
|
||||
.args(["aux"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = ps_output {
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
let mut processes = Vec::new();
|
||||
let mut mt5_count = 0;
|
||||
let mut wine_server = false;
|
||||
|
||||
for line in content.lines() {
|
||||
let line_lower = line.to_lowercase();
|
||||
|
||||
if line_lower.contains("terminal64") || line_lower.contains("metatrader") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 11 {
|
||||
processes.push(json!({
|
||||
"pid": parts[1],
|
||||
"cpu": parts[2],
|
||||
"mem": parts[3],
|
||||
"command": parts[10..].join(" "),
|
||||
}));
|
||||
mt5_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if line_lower.contains("wineserver") {
|
||||
wine_server = true;
|
||||
}
|
||||
}
|
||||
|
||||
result["processes"] = json!(processes);
|
||||
result["is_running"] = json!(mt5_count > 0);
|
||||
result["total_instances"] = json!(mt5_count);
|
||||
result["wine_server_running"] = json!(wine_server);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let ps_output = Command::new("ps")
|
||||
.args(["aux"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = ps_output {
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
let mut processes = Vec::new();
|
||||
let mut mt5_count = 0;
|
||||
let mut wine_server = false;
|
||||
|
||||
for line in content.lines() {
|
||||
let line_lower = line.to_lowercase();
|
||||
|
||||
if line_lower.contains("terminal64") || line_lower.contains("metatrader") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 11 {
|
||||
processes.push(json!({
|
||||
"pid": parts[1],
|
||||
"cpu": parts[2],
|
||||
"mem": parts[3],
|
||||
"command": parts[10..].join(" "),
|
||||
}));
|
||||
mt5_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if line_lower.contains("wineserver") {
|
||||
wine_server = true;
|
||||
}
|
||||
}
|
||||
|
||||
result["processes"] = json!(processes);
|
||||
result["is_running"] = json!(mt5_count > 0);
|
||||
result["total_instances"] = json!(mt5_count);
|
||||
result["wine_server_running"] = json!(wine_server);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Kill stuck MT5 process
|
||||
pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<Value> {
|
||||
use std::process::Command;
|
||||
|
||||
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let pid = args.get("pid").and_then(|v| v.as_str());
|
||||
|
||||
let mut killed = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
// Get list of MT5 processes
|
||||
let ps_output = Command::new("ps")
|
||||
.args(["aux"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = ps_output {
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
for line in content.lines() {
|
||||
let line_lower = line.to_lowercase();
|
||||
|
||||
let should_kill = if let Some(target_pid) = pid {
|
||||
line.contains(target_pid) && (line_lower.contains("terminal64") || line_lower.contains("metatrader"))
|
||||
} else {
|
||||
line_lower.contains("terminal64") || line_lower.contains("metatrader")
|
||||
};
|
||||
|
||||
if should_kill {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let process_pid = parts[1];
|
||||
let signal = if force { "-9" } else { "-15" };
|
||||
|
||||
match Command::new("kill").args([signal, process_pid]).output() {
|
||||
Ok(_) => killed.push(process_pid.to_string()),
|
||||
Err(e) => failed.push(format!("{}: {}", process_pid, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also kill wineserver if force=true
|
||||
if force {
|
||||
let _ = Command::new("killall").arg("wineserver").output();
|
||||
}
|
||||
}
|
||||
|
||||
let message = if killed.is_empty() {
|
||||
"No MT5 processes found to kill".to_string()
|
||||
} else {
|
||||
format!("Killed {} MT5 process(es)", killed.len())
|
||||
};
|
||||
|
||||
let result = json!({
|
||||
"killed": killed,
|
||||
"failed": failed,
|
||||
"force": force,
|
||||
"message": message,
|
||||
});
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": !failed.is_empty()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check system resources for MT5
|
||||
pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> Result<Value> {
|
||||
use std::process::Command;
|
||||
|
||||
let mut result = json!({
|
||||
"disk_space": null,
|
||||
"memory": null,
|
||||
"cpu_cores": 0,
|
||||
"recommendations": Vec::<String>::new(),
|
||||
});
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
// Check disk space
|
||||
let df_output = Command::new("df")
|
||||
.args(["-h", "/"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = df_output {
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
for line in content.lines().skip(1) {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 5 {
|
||||
result["disk_space"] = json!({
|
||||
"filesystem": parts[0],
|
||||
"size": parts[1],
|
||||
"used": parts[2],
|
||||
"available": parts[3],
|
||||
"use_percent": parts[4],
|
||||
});
|
||||
|
||||
// Check if low on space
|
||||
let use_pct = parts[4].trim_end_matches('%').parse::<u32>().unwrap_or(0);
|
||||
if use_pct > 90 {
|
||||
result["recommendations"].as_array_mut().unwrap().push(
|
||||
json!("Disk space critically low. Clean MT5 cache with clean_cache.")
|
||||
);
|
||||
} else if use_pct > 80 {
|
||||
result["recommendations"].as_array_mut().unwrap().push(
|
||||
json!("Disk space getting low. Consider cleaning cache.")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check memory
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let vm_output = Command::new("vm_stat").output();
|
||||
if let Ok(output) = vm_output {
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
// Parse vm_stat output
|
||||
let mut free_pages = 0u64;
|
||||
let mut active_pages = 0u64;
|
||||
let mut inactive_pages = 0u64;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.contains("Pages free:") {
|
||||
free_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
|
||||
} else if line.contains("Pages active:") {
|
||||
active_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
|
||||
} else if line.contains("Pages inactive:") {
|
||||
inactive_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
let page_size = 4096u64;
|
||||
let total_mb = ((free_pages + active_pages + inactive_pages) * page_size) / 1024 / 1024;
|
||||
let free_mb = (free_pages * page_size) / 1024 / 1024;
|
||||
|
||||
result["memory"] = json!({
|
||||
"total_mb": total_mb,
|
||||
"free_mb": free_mb,
|
||||
"unit": "MB",
|
||||
});
|
||||
|
||||
if free_mb < 2048 {
|
||||
result["recommendations"].as_array_mut().unwrap().push(
|
||||
json!("Low memory available. MT5 may crash during large optimizations.")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mem_output = Command::new("free").args(["-m"]).output();
|
||||
if let Ok(output) = mem_output {
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
for line in content.lines() {
|
||||
if line.starts_with("Mem:") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 4 {
|
||||
result["memory"] = json!({
|
||||
"total_mb": parts[1].parse::<u64>().unwrap_or(0),
|
||||
"used_mb": parts[2].parse::<u64>().unwrap_or(0),
|
||||
"free_mb": parts[3].parse::<u64>().unwrap_or(0),
|
||||
"unit": "MB",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get CPU cores
|
||||
let nproc_output = Command::new("sysctl")
|
||||
.args(["-n", "hw.ncpu"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = nproc_output {
|
||||
let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0);
|
||||
result["cpu_cores"] = json!(cores);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Validate MT5 configuration files
|
||||
pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Result<Value> {
|
||||
let mt5_dir = config.mt5_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
|
||||
|
||||
let mut result = json!({
|
||||
"terminal_ini": null,
|
||||
"tester_ini": null,
|
||||
"config_files_found": Vec::<String>::new(),
|
||||
"errors": Vec::<String>::new(),
|
||||
"warnings": Vec::<String>::new(),
|
||||
});
|
||||
|
||||
// Check terminal.ini
|
||||
let terminal_ini = mt5_dir.join("terminal.ini");
|
||||
if terminal_ini.exists() {
|
||||
result["config_files_found"].as_array_mut().unwrap().push(json!("terminal.ini"));
|
||||
|
||||
if let Ok(content) = fs::read_to_string(&terminal_ini) {
|
||||
// Check for common issues
|
||||
if !content.contains("[Common]") {
|
||||
result["errors"].as_array_mut().unwrap().push(
|
||||
json!("terminal.ini missing [Common] section")
|
||||
);
|
||||
}
|
||||
|
||||
// Extract key settings
|
||||
let mut settings = serde_json::Map::new();
|
||||
for line in content.lines() {
|
||||
if line.starts_with("Login=") {
|
||||
settings.insert("login".to_string(), json!(line.trim_start_matches("Login=")));
|
||||
} else if line.starts_with("Server=") {
|
||||
settings.insert("server".to_string(), json!(line.trim_start_matches("Server=")));
|
||||
} else if line.starts_with("Expert=") {
|
||||
settings.insert("expert".to_string(), json!(line.trim_start_matches("Expert=")));
|
||||
}
|
||||
}
|
||||
result["terminal_ini"] = json!(settings);
|
||||
} else {
|
||||
result["errors"].as_array_mut().unwrap().push(
|
||||
json!("Could not read terminal.ini")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
result["warnings"].as_array_mut().unwrap().push(
|
||||
json!("terminal.ini not found")
|
||||
);
|
||||
}
|
||||
|
||||
// Check for tester config
|
||||
let tester_dir = mt5_dir.join("Tester");
|
||||
if tester_dir.exists() {
|
||||
if let Ok(entries) = fs::read_dir(&tester_dir) {
|
||||
let ini_files: Vec<String> = entries
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let p = e.path();
|
||||
if p.extension()?.to_str()? == "ini" {
|
||||
Some(p.file_name()?.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !ini_files.is_empty() {
|
||||
result["tester_ini"] = json!(ini_files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for common problems
|
||||
let experts_dir = mt5_dir.join("MQL5").join("Experts");
|
||||
if !experts_dir.exists() {
|
||||
result["errors"].as_array_mut().unwrap().push(
|
||||
json!("MQL5/Experts directory not found - MT5 installation may be incomplete")
|
||||
);
|
||||
}
|
||||
|
||||
let has_errors = !result["errors"].as_array().unwrap().is_empty();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": has_errors
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get Wine prefix detailed information
|
||||
pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Result<Value> {
|
||||
let mt5_dir = config.mt5_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
|
||||
|
||||
let wine_prefix = mt5_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent());
|
||||
|
||||
let mut result = json!({
|
||||
"prefix_path": null,
|
||||
"exists": false,
|
||||
"windows_version": null,
|
||||
"dll_overrides": Vec::<String>::new(),
|
||||
"installed_programs": Vec::<String>::new(),
|
||||
"registry_files": Vec::<String>::new(),
|
||||
"drive_c_size_mb": 0,
|
||||
});
|
||||
|
||||
if let Some(prefix) = wine_prefix {
|
||||
result["prefix_path"] = json!(prefix.to_string_lossy().to_string());
|
||||
result["exists"] = json!(prefix.exists());
|
||||
|
||||
if prefix.exists() {
|
||||
// Check Windows version
|
||||
let system_reg = prefix.join("system.reg");
|
||||
if system_reg.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&system_reg) {
|
||||
for line in content.lines().take(50) {
|
||||
if line.contains("\"ProductName\"") {
|
||||
let parts: Vec<&str> = line.split('"').collect();
|
||||
if parts.len() >= 4 {
|
||||
result["windows_version"] = json!(parts[3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate drive_c size
|
||||
let drive_c = prefix.join("drive_c");
|
||||
if drive_c.exists() {
|
||||
let mut size = 0u64;
|
||||
fn calc_size(dir: &Path, total: &mut u64) {
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_file() {
|
||||
if let Ok(m) = e.metadata() {
|
||||
*total += m.len();
|
||||
}
|
||||
} else if p.is_dir() {
|
||||
calc_size(&p, total);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
calc_size(&drive_c, &mut size);
|
||||
result["drive_c_size_mb"] = json!((size / 1024 / 1024) as i64);
|
||||
}
|
||||
|
||||
// Check for installed programs
|
||||
let prog_files = prefix.join("drive_c").join("Program Files");
|
||||
if prog_files.exists() {
|
||||
if let Ok(entries) = fs::read_dir(&prog_files) {
|
||||
let programs: Vec<String> = entries
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
Some(p.file_name()?.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
result["installed_programs"] = json!(programs);
|
||||
}
|
||||
}
|
||||
|
||||
// List registry files
|
||||
let reg_files: Vec<String> = vec![
|
||||
"system.reg", "user.reg", "userdef.reg"
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|f| prefix.join(f).exists())
|
||||
.map(|f| f.to_string())
|
||||
.collect();
|
||||
result["registry_files"] = json!(reg_files);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get backtest crash/failure information
|
||||
pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir").and_then(|v| v.as_str());
|
||||
let check_recent = args.get("check_recent").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(6);
|
||||
|
||||
let mut result = json!({
|
||||
"crashes_found": Vec::<serde_json::Value>::new(),
|
||||
"recent_failures": 0,
|
||||
"common_patterns": Vec::<String>::new(),
|
||||
});
|
||||
|
||||
// Check specific report directory if provided
|
||||
if let Some(dir) = report_dir {
|
||||
let path = Path::new(dir);
|
||||
if path.exists() {
|
||||
// Check for incomplete markers
|
||||
let incomplete_marker = path.join(".incomplete");
|
||||
let error_log = path.join("error.log");
|
||||
|
||||
if incomplete_marker.exists() {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "incomplete",
|
||||
"reason": "Backtest was interrupted or timed out",
|
||||
}));
|
||||
}
|
||||
|
||||
if error_log.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&error_log) {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "error_log",
|
||||
"content": content.lines().take(20).collect::<Vec<_>>().join("\n"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if deals.csv is missing or empty
|
||||
let deals_csv = path.join("deals.csv");
|
||||
if !deals_csv.exists() {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "missing_deals",
|
||||
"reason": "deals.csv not found - backtest likely failed",
|
||||
}));
|
||||
} else if let Ok(meta) = deals_csv.metadata() {
|
||||
if meta.len() < 100 {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "empty_deals",
|
||||
"reason": "deals.csv is nearly empty - no trades were made",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check recent reports if requested
|
||||
if check_recent {
|
||||
let reports_dir_str = config.get("reports_dir");
|
||||
let reports_dir = Path::new(&reports_dir_str);
|
||||
if reports_dir.exists() {
|
||||
let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(hours_back * 3600);
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&reports_dir) {
|
||||
let mut failures = 0;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if modified >= cutoff {
|
||||
// Check for failure indicators
|
||||
let has_deals = path.join("deals.csv").exists();
|
||||
let has_incomplete = path.join(".incomplete").exists();
|
||||
|
||||
if !has_deals || has_incomplete {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result["recent_failures"] = json!(failures);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze common patterns
|
||||
let crashes = result["crashes_found"].as_array().unwrap();
|
||||
if !crashes.is_empty() {
|
||||
let types: Vec<String> = crashes.iter()
|
||||
.filter_map(|c| c.get("type").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
if types.contains(&"missing_deals".to_string()) {
|
||||
result["common_patterns"].as_array_mut().unwrap().push(
|
||||
json!("Missing deals.csv suggests MT5 crashed during backtest")
|
||||
);
|
||||
}
|
||||
if types.contains(&"incomplete".to_string()) {
|
||||
result["common_patterns"].as_array_mut().unwrap().push(
|
||||
json!("Incomplete markers indicate interruptions - check system resources")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user