Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0dc5ce0c0c | |||
| 1d817c9bed | |||
| 17bb7545ba | |||
| 52c3d91639 | |||
| e954883189 | |||
| 50025968ff | |||
| 6ccc066b6a | |||
| 3ad67d4c57 | |||
| 4c34ae236c | |||
| ecf5606b7a | |||
| 6bfb330b5d | |||
| 52a3d44b99 | |||
| caabc2c03b | |||
| 62c1ca773e | |||
| 3b9d4ce2dd | |||
| 12fba8ad4b | |||
| b6171d0c56 | |||
| 05214ff34b | |||
| 498126dfd4 | |||
| 147da213d6 | |||
| 804c1f8808 | |||
| f8b6a11bb8 | |||
| e82bb5466b | |||
| 50ea45f8cd | |||
| 4bc5ca22e9 | |||
| e44345a05f | |||
| 01514623c9 | |||
| bce0323469 | |||
| ccc9e7e4d8 | |||
| e1adcad45c | |||
| b97011d5b6 | |||
| 41344b14b8 | |||
| 436566e019 | |||
| 74c3fdb87e | |||
| 55e0822051 | |||
| 5366b45c36 | |||
| 96ba882a95 | |||
| b9729b7121 | |||
| e7d2ec5a47 | |||
| 7620cb3c04 | |||
| 9a8594426a | |||
| 061e36d339 | |||
| cc4b702176 | |||
| e58316e70e | |||
| a1c5d328b3 | |||
| 6f773d6f09 | |||
| c895eb9b33 | |||
| e0b1356afa | |||
| 23a0b7a375 | |||
| ac5ec453d3 | |||
| 9f6c67d0ef | |||
| 262936a1d5 | |||
| 0ea1e779e0 | |||
| a94d75823a | |||
| c7e1a03e1f | |||
| 23c67e80c5 | |||
| 5db3c7c822 | |||
| 5eb753584d | |||
| 7c9eeef1c4 | |||
| 20a2d3d6e4 | |||
| ec2a22ed30 | |||
| 94a8c42e68 | |||
| 7e97086909 | |||
| 7a8a8daeca | |||
| ba04405454 | |||
| e40dbf63cf | |||
| 0a2e076ad6 | |||
| 8f71f1629d | |||
| ad17f9dba6 | |||
| 33186d276a | |||
| 3927343999 | |||
| 6a5253186b | |||
| 76dac96583 | |||
| 0bc410f613 | |||
| 9be1296916 | |||
| 63c0470c6b | |||
| a1434914e9 | |||
| 6ce8808948 | |||
| 896aa6111e | |||
| aab34a1fde | |||
| 96c7e0a9c2 | |||
| 0c82501e18 | |||
| 2fef0db3c4 | |||
| 95ceef3e3f | |||
| 662e9e324c | |||
| 6d1f940887 | |||
| 41c8b36826 | |||
| 42318b040a | |||
| fdb45b8eed | |||
| b63bda64ab | |||
| cbed3d15af | |||
| 8cce92c409 | |||
| 2b136b845b | |||
| d41c51bae4 | |||
| eae612df75 | |||
| 94eb8927fd | |||
| 2104fabe50 | |||
| 51f19640c3 | |||
| 1ec0adb6e6 | |||
| 397f893bfc |
@@ -0,0 +1,4 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: masdevid
|
||||
custom: ["https://www.paypal.me/devidhw", "https://saweria.co/depod"]
|
||||
@@ -0,0 +1,11 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "" # See documentation for possible values
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
@@ -0,0 +1,425 @@
|
||||
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
|
||||
|
||||
# ── Cargo Publish ────────────────────────────────────────────────────────────
|
||||
|
||||
cargo-publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
target
|
||||
key: cargo-publish-${{ hashFiles('Cargo.lock') }}
|
||||
restore-keys: cargo-publish-
|
||||
|
||||
- name: Publish to crates.io
|
||||
run: cargo publish --no-verify --allow-dirty
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||
|
||||
# ── GitHub Release ───────────────────────────────────────────────────────────
|
||||
|
||||
release:
|
||||
needs: [build-macos, build-linux, cargo-publish]
|
||||
|
||||
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
|
||||
+10
-3
@@ -19,14 +19,21 @@ config/mt5-quant.yaml
|
||||
config/baseline.json
|
||||
config/backtest_history.json
|
||||
|
||||
# Claude Code (personal, not for repo)
|
||||
# Agent configs (personal LLM instructions)
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
.ocx
|
||||
.opencode
|
||||
AGENTS.md
|
||||
|
||||
# Windsurf (local workflows, not for repo)
|
||||
.windsurf/
|
||||
|
||||
# GitHub Actions (local workflows, not for repo)
|
||||
.github/workflows/
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Codegraph
|
||||
.codegraph/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
# AGENTS.md — Developer instructions for MT5-Quant
|
||||
|
||||
## Project Overview
|
||||
|
||||
Open-source MCP server for MetaTrader 5 backtesting, optimization, and analytics — written entirely in Rust. Exposes 89 stdio MCP tools for compiling MQL5 EAs, running backtests via Wine, extracting deal-level data, organizing reports in SQLite, and performing 19 dimensions of analysis.
|
||||
|
||||
Target users: MQL developers on macOS (CrossOver or native MetaTrader5.app) and Linux (Wine/Xvfb).
|
||||
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
MT5-Quant/
|
||||
├── src/
|
||||
│ ├── main.rs # CLI entry + stdio MCP transport
|
||||
│ ├── mcp_server.rs # McpServer struct, tool dispatch, init/notification
|
||||
│ ├── mt5.rs # Wine/MT5 executable launchers
|
||||
│ ├── models/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── config.rs # Config, CurrentAccount, wine detection, .set helpers
|
||||
│ │ ├── deals.rs # Deal, PositionPair, DrawdownEvent
|
||||
│ │ ├── metrics.rs # BacktestMetrics parsing (HTML + XML formats)
|
||||
│ │ └── report.rs # ReportEntry, PipelineMetadata, BacktestJob, status
|
||||
│ ├── analytics/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── extract.rs # HTML/XML report → Deal[] + metrics
|
||||
│ │ └── analyze.rs # DealAnalyzer: 19+ analysis methods
|
||||
│ ├── compile/
|
||||
│ │ ├── mod.rs
|
||||
│ │ └── mql_compiler.rs # MetaEditor compiler via Wine
|
||||
│ ├── pipeline/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── backtest.rs # 5-stage pipeline: COMPILE→CLEAN→BACKTEST→EXTRACT→ANALYZE
|
||||
│ │ └── stages.rs # PipelineStage enum
|
||||
│ ├── optimization/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── optimizer.rs # Background optimization launcher
|
||||
│ │ └── parser.rs # Optimization result XML → best params
|
||||
│ ├── storage/
|
||||
│ │ ├── mod.rs
|
||||
│ │ └── database.rs # ReportDb: reports + deals SQLite tables, queries
|
||||
│ ├── utils/ # (new) shared utilities — growing directory
|
||||
│ └── tools/
|
||||
│ ├── mod.rs # MCP definitions aggregation
|
||||
│ ├── definitions/ # Tool schemas (JSON Schema) — 11 modules, 90 tools
|
||||
│ │ ├── mod.rs # exports: all_tools(), system_tools, etc.
|
||||
│ │ ├── analytics.rs # 19 analysis tools + compare_baseline
|
||||
│ │ ├── backtest.rs # 7 backtest tools
|
||||
│ │ ├── baseline.rs # 1 baseline tool
|
||||
│ │ ├── experts.rs # 9 EA/indicator/script tools
|
||||
│ │ ├── optimization.rs # 4 optimization tools
|
||||
│ │ ├── reports.rs # 20 report management tools
|
||||
│ │ ├── setfiles.rs # 8 .set file tools
|
||||
│ │ ├── system.rs # 6 system/health tools + version update
|
||||
│ │ ├── update.rs # update tool definition
|
||||
│ │ └── utility.rs # 10 pre-flight/compat tools
|
||||
│ └── handlers/ # Tool dispatch functions — 11 modules
|
||||
│ ├── mod.rs # ToolHandler struct, dispatch table (89+ cases)
|
||||
│ ├── analysis.rs # analytics/analyze dispatch
|
||||
│ ├── backtest.rs # pipeline/backtest dispatch
|
||||
│ ├── experts.rs # wine + WalkDir helpers: scan_mql_dir, copy_mql_to_project
|
||||
│ ├── optimization.rs # optimizer dispatch
|
||||
│ ├── reports.rs # ReportDb + ReportEntry dispatch
|
||||
│ ├── setfiles.rs # UTF-16LE .set read/write/patch helpers
|
||||
│ ├── system.rs # healthcheck, version update
|
||||
│ ├── utility.rs # pre-flight, diagnostics, init_project helpers
|
||||
│ └── update.rs # update tool handler
|
||||
├── scripts/
|
||||
│ ├── setup.sh # Auto-detect Wine/MT5, write config (1271 lines)
|
||||
│ ├── platform_detect.sh # Wine path + headless detection
|
||||
│ ├── build-rust.sh # cargo build --release (25 lines)
|
||||
│ ├── release.sh # Version bump + changelog + tag + push (238 lines)
|
||||
│ └── optimize.sh # Legacy optimization driver (pending full Rust migration)
|
||||
├── analytics/ # Legacy Python (reference only — do not modify)
|
||||
├── config/
|
||||
│ ├── mt5-quant.example.yaml # Template user copies to mt5-quant.yaml
|
||||
│ └── mt5-quant.yaml # Generated config (gitignored)
|
||||
├── tests/
|
||||
│ ├── test_mcp.py # End-to-end MCP tests (stdio-driven)
|
||||
│ ├── integration_test.sh # Shell-based integration tests
|
||||
│ └── fixtures/ # Sample reports + CSVs for testing
|
||||
├── server.json # MCP registry manifest (tracked)
|
||||
├── Cargo.toml # version = 1.32.4
|
||||
└── CHANGELOG.md
|
||||
```
|
||||
|
||||
## Key Design Constraints
|
||||
|
||||
- **Headless/GUI**: `platform_detect.sh` auto-selects. macOS CrossOver = GUI (CrossOver abstracts display). Linux without `$DISPLAY` = Xvfb. Controlled by `display.mode` in config (`auto` | `gui` | `headless`).
|
||||
- **Single MT5 instance**: Never run two backtests in parallel on the same Wine prefix.
|
||||
- **Model 0 only for optimization**: Model 1 overfits martingale/grid EAs — intra-bar movement not simulated. Use `model=0` (every tick).
|
||||
- **UTF-16LE .set files**: MT5 strips `||Y` flags from UTF-8. All `.set` writes use UTF-16LE + `chmod 444`. See `setfiles.rs` helpers.
|
||||
- **OptMode reset**: After any optimization, `terminal.ini` gets `OptMode=-1`. Must reset to `0` before next backtest (handled in `backtest.rs` cleanup stage).
|
||||
- **Report format detection**: MT5 Build 48+ writes SpreadsheetML XML (`.htm.xml`), not HTML. Both formats handled in `extract.rs` — always check for the XML variant first.
|
||||
- **EA-agnostic**: Never hardcode magic numbers, strategy names, or assume specific EA behavior. All code must work with any MQL5 EA.
|
||||
|
||||
## Architecture
|
||||
|
||||
1. **CLI (`main.rs`)** — parses CLi args (`--stdio`, `--port`, `--test-launch`), initializes `McpServer`, runs stdio or TCP transport loop, handles JSON-RPC requests.
|
||||
|
||||
2. **McpServer (`mcp_server.rs`)** — wraps `ToolHandler` with `Arc<Mutex>`, handles `initialize`/`notifications`/`tool/error` method routing. Auto-verifies Wine/MT5 on first call.
|
||||
|
||||
3. **ToolHandler (`tools/handlers/mod.rs`)** — dispatch table mapping 89 tool names to handler functions. Each handler module is self-contained.
|
||||
|
||||
4. **Definitions (`tools/definitions/`)** — JSON Schema objects for tool input/output. Never executes logic.
|
||||
|
||||
5. **Analytics (`analytics/`)** — two core structs:
|
||||
- `ReportExtractor` — reads HTML/XML report + tester log → `Vec<Deal>` + `BacktestMetrics`
|
||||
- `DealAnalyzer` — takes `Vec<Deal>` → 19 analysis methods (monthly PnL, drawdown events, streaks, etc.)
|
||||
|
||||
6. **Pipeline (`pipeline/backtest.rs`)** — `BacktestPipeline` struct with 5 stages:
|
||||
- `COMPILE` — `MqlCompiler::compile()` via MetaEditor
|
||||
- `CLEAN` — clean cache, reset OptMode
|
||||
- `BACKTEST` — launch MT5 tester via Wine, poll status
|
||||
- `EXTRACT` — parse HTML/XML → deals + metrics
|
||||
- `ANALYZE` — run all analytics, write to SQLite
|
||||
|
||||
7. **Storage (`storage/database.rs`)** — `ReportDb` with two tables:
|
||||
- `reports` — metrics, set file paths, verdict, tags, notes
|
||||
- `deals` — individual deal rows, keyed by `report_id`
|
||||
- `CREATE TABLE IF NOT EXISTS` in `init()` — idempotent on startup
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
bash scripts/build-rust.sh
|
||||
# or
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Binary: `target/release/mt5-quant`
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit/integration:** `cargo test`
|
||||
- **End-to-end MCP:** `python3 tests/test_mcp.py` — drives the MCP server over stdio (full backtest + all analytics)
|
||||
- **Analytics only:** No MT5/Wine needed; loads deals from SQLite DB via `db.get_deals(report_id)`
|
||||
- **Symbol note:** Testing account uses `XAUUSDc` (broker suffix required), not `XAUUSD`
|
||||
|
||||
## Configuration
|
||||
|
||||
Users copy `config/mt5-quant.example.yaml` → `config/mt5-quant.yaml` (gitignored).
|
||||
Run `bash scripts/setup.sh` to auto-detect Wine, MT5 paths, architecture, and write config.
|
||||
|
||||
Minimum config:
|
||||
```yaml
|
||||
wine_executable: "/path/to/wine64"
|
||||
terminal_dir: "/path/to/MetaTrader 5"
|
||||
```
|
||||
|
||||
Optional:
|
||||
```yaml
|
||||
defaults:
|
||||
symbol: "XAUUSD.cent"
|
||||
timeframe: "M5"
|
||||
deposit: 10000
|
||||
display:
|
||||
mode: auto # auto | gui | headless
|
||||
reports_dir: "./reports"
|
||||
experts_dir: "~/.../MQL5/Experts"
|
||||
```
|
||||
|
||||
## Developing New Tools
|
||||
|
||||
Each new tool follows a 4-file pattern:
|
||||
|
||||
1. **Schema** — Add tool input/output to `tools/definitions/{domain}.rs` via `Tool::new(name) {...}` with `Input::schema` and `Output::schema`.
|
||||
2. **Export** — Add to `tools/definitions/mod.rs`:
|
||||
- Include in `all_tools()` array
|
||||
- Add to domain exports if creating new domain
|
||||
3. **Handler** — Add function in `tools/handlers/{domain}.rs`:
|
||||
```rust
|
||||
pub async fn handle_tool_name(config: &Config, args: &Value) -> Result<Value> {
|
||||
let required = required_str(args, "param_name")?;
|
||||
ok_response(json! { "result": "..." })
|
||||
}
|
||||
```
|
||||
4. **Dispatch** — Add case in `handlers/mod.rs` match arm under `ToolHandler::handle()`.
|
||||
|
||||
### Handler Helper Functions (use instead of inline logic)
|
||||
|
||||
| Helper | Purpose |
|
||||
|--------|---------|
|
||||
| `required_str(args, key)` | Extract required `&str` or return error |
|
||||
| `ok_response(data)` / `err_response(msg)` | Wrap in MCP content envelope |
|
||||
| `resolve_report(args)` | → `(deals, metrics, report_dir)` — `report_id > report_dir > latest` |
|
||||
| `prepare_analysis(args)` | → `(deals, metrics, analyzer, report_dir)` — wraps resolve_report |
|
||||
| `scan_mql_dir(dir, filter, type_label)` | WalkDir with optional filter for list/search |
|
||||
| `copy_mql_to_project(config, args, fallback)` | Shared copy logic for indicator/script |
|
||||
|
||||
Extend helpers when adding new handlers.
|
||||
|
||||
## Commit Style
|
||||
|
||||
Conventional commits: `feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `release:`
|
||||
Keep commits focused — one logical change per commit.
|
||||
|
||||
## Release Process
|
||||
|
||||
```bash
|
||||
bash scripts/release.sh <patch|minor|major|X.Y.Z> [--yes]
|
||||
```
|
||||
|
||||
Script handles:
|
||||
1. Bump `Cargo.toml` + `Cargo.lock` version
|
||||
2. Update `server.json` (version + SHA256 placeholder)
|
||||
3. Generate CHANGELOG entry from git log
|
||||
4. `cargo check --quiet`
|
||||
5. Commit `release: vX.Y.Z`
|
||||
6. Tag `vX.Y.Z`
|
||||
7. Push — CI (`release.yml`) triggered:
|
||||
- Build macOS arm64 + Linux x64 binaries
|
||||
- Publish to crates.io
|
||||
- Create GitHub Release with artifacts
|
||||
- Build MCP package, compute SHA256
|
||||
- Update `server.json` SHA256, push
|
||||
- Publish to MCP Registry
|
||||
|
||||
## Deal Storage
|
||||
|
||||
- Deals stored in `deals` SQLite table, keyed by `report_id`. **No `deals.csv` written automatically.**
|
||||
- `db.insert_deals(report_id, &[Deal])` called in pipeline after extraction.
|
||||
- `db.get_deals(report_id)` loads deals for analytics. `db.get_by_report_dir(path)` resolves by filesystem path.
|
||||
- `export_deals_csv` tool writes CSV on demand.
|
||||
|
||||
## DB Schema Evolution
|
||||
|
||||
Add new tables/columns in `ReportDb::init()` using `CREATE TABLE IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS` — runs on every startup, is idempotent. No migration files needed.
|
||||
|
||||
## Wine / MT5 Notes
|
||||
|
||||
- Wine executable paths differ across platforms:
|
||||
- macOS MT5.app: `/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64`
|
||||
- macOS CrossOver: `/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine64`
|
||||
- Linux: `/usr/bin/wine64` (or first `wine64` on PATH)
|
||||
- MT5 terminal_dir is auto-resolved from wine_executable path.
|
||||
- Apple Silicon: append `arch -x86_64` prefix for x86 Wine binaries (auto-detected).
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **M1/Memory issues**: Wine on Apple Silicon runs x86 via Rosetta 2. Watch memory pressure.
|
||||
- **Model mismatch**: Grid/martingale EAs need `model=0` (every tick). Model 1/2 will produce zero deals.
|
||||
- **Set file encoding**: Always use UTF-16LE with `chmod 444`. UTF-8 strips `||Y` flags.
|
||||
- **OptMode=-1**: Must clear after optimization. Handled in pipeline cleanup stage.
|
||||
- **Report not found**: Check `<terminal_dir>/history/` for correct symbol name (broker suffix matters: `XAUUSDc` vs `XAUUSD`).
|
||||
- **Journal-only backtest**: `ShutdownTerminal=1` doesn't exit MT5 on Wine. Inactivity watchdog (in `launch_backtest`) handles this with `inactivity_kill_secs` param.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Changelog
|
||||
|
||||
## [1.33.0] — 2026-06-25
|
||||
|
||||
### Added
|
||||
- **`src/utils/` module**: shared `read_file_as_utf8` utility for UTF-16LE BOM detection, used across set file and config handlers.
|
||||
- **OS detection and diagnostics**: `utility.rs` gains `OsType` enum, `get_os_context`, `get_system_memory`, and `crossover_server_running` helper for better CrossOver diagnostics.
|
||||
- **Wine binary detection**: MT5.app and CrossOver now detect both `wine` and `wine64` binaries; Homebrew and Linux paths add `wine` alongside `wine64`.
|
||||
|
||||
### Changed
|
||||
- **Optimizer launch mechanism**: rewritten from `/mt5mcp_backtest.ini` + `.bat` to `/config:` INI + shell script. Now patches `terminal.ini` directly with full optimization parameters and appends agent configuration — no longer relies on batch file or separate INI.
|
||||
- **OptMode reset**: removed as a separate step; now included inline in `terminal.ini` patching during optimizer startup.
|
||||
- **Wine prefix resolution**: changed from 2-parent to 3-parent traversal, matching the backtest pipeline's Wine prefix logic.
|
||||
- **Set file parsing**: parameter delimiter changed from `:` to `=` (matches actual MT5 .set format); `||Y` sweep param parsing rewritten to split on `||` for accurate range extraction.
|
||||
- **OptimizationParams**: `model` parameter removed (was hardcoded to `0` everywhere anyway; MT5 now defaults correctly).
|
||||
|
||||
### Fixed
|
||||
- **Set file cross-platform reading**: `.set` files are now read as UTF-16LE (with BOM) or UTF-8 regardless of platform, fixing potential encoding issues on macOS/Linux.
|
||||
- **Read-only set file overwrite**: handles pre-existing read-only files during `.set` write by removing them first.
|
||||
|
||||
|
||||
## [1.32.4] — 2026-04-25
|
||||
|
||||
### Fixed
|
||||
- **HTML report extraction**: `ShutdownTerminal=1` does not cause `terminal64.exe` to exit on Wine/macOS.
|
||||
Inactivity watchdog now waits 30 s for the report file after the tester log goes quiet, then kills
|
||||
MT5 unconditionally — no longer depends on natural process exit.
|
||||
- **Duplicate deals**: tester agent log writes each deal twice; deduplicated via `HashSet<deal_number>`
|
||||
in `parse_journal_deals`.
|
||||
- **Entry direction inference**: all journal deals were marked `entry="in"` because no direction info
|
||||
exists in the log. Fixed with a per-symbol position tracker (signed lot accumulation) to infer
|
||||
"in" / "out" correctly.
|
||||
- **`list_deals` returning 0 results**: `is_closed_trade()` was gating on `profit != 0.0`; journal
|
||||
deals legitimately have zero profit. Removed the profit gate.
|
||||
- **Wrong tester log selected**: `Agent-0.0.0.0` logs only contain startup lines, not deal lines.
|
||||
`find_active_tester_agent_log` now prefers `Agent-127.0.0.1` and tiebreaks by file size.
|
||||
|
||||
### Added
|
||||
- `launch_backtest` exposes `shutdown` (default `true`) and `inactivity_kill_secs` (default: disabled;
|
||||
set to e.g. `120` to enable the inactivity watchdog) as explicit tool parameters.
|
||||
- Report DB stores deals in SQLite; all analytics tools resolve by `report_id` / `report_dir` / latest.
|
||||
|
||||
### Verified
|
||||
- 1-month DPS21/XAUUSD.cent/M5 backtest produces full HTML report: win_rate=70%,
|
||||
profit_factor=0.77, sharpe=-3.61, max_dd=6.93%. All 17 analytics tools return real data.
|
||||
|
||||
|
||||
## [1.31.5] — 2026-04-22
|
||||
|
||||
- feat: add check_update and update tools with background auto-check
|
||||
- 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.27.0"
|
||||
version = "1.32.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+12
-2
@@ -1,9 +1,19 @@
|
||||
[package]
|
||||
name = "mt5-quant"
|
||||
version = "1.27.0"
|
||||
version = "1.33.0"
|
||||
edition = "2021"
|
||||
description = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP"
|
||||
description = "MCP server for MT5 strategy development on macOS/Linux"
|
||||
authors = ["masdevid <masdevid@example.com>"]
|
||||
license = "MIT"
|
||||
repository = "https://github.com/masdevid/mt5-quant"
|
||||
readme = "README.md"
|
||||
keywords = ["mt5", "mql5", "trading", "mcp", "backtest"]
|
||||
categories = ["finance", "development-tools"]
|
||||
homepage = "https://github.com/masdevid/mt5-quant"
|
||||
documentation = "https://github.com/masdevid/mt5-quant"
|
||||
|
||||
[package.metadata]
|
||||
maintenance = { status = "actively-developed" }
|
||||
|
||||
[[bin]]
|
||||
name = "mt5-quant"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MT5-Quant
|
||||
|
||||
**MCP server for MT5 strategy development on macOS/Linux.** 43 tools to compile, backtest, analyze, and optimize MQL5 Expert Advisors — no Windows required.
|
||||
**MCP server for MT5 strategy development on macOS/Linux.** 89 tools to compile, backtest, analyze, optimize, debug crashes, and manage MQL5 Expert Advisors — no Windows required.
|
||||
|
||||
```
|
||||
You: "Backtest MyEA Jan-Mar, what caused the February drawdown?"
|
||||
@@ -13,54 +13,55 @@ Claude: [compile → clean → backtest → analyze 1,847 deals]
|
||||
|
||||
## Why MT5-Quant
|
||||
|
||||
| | MT5-Quant | Other MT5 MCPs | QuantConnect |
|
||||
|---|---|---|---|
|
||||
| Backtest pipeline | ✅ Full | ❌ | Cloud only |
|
||||
| Deal-level analytics | ✅ 15+ dims | ❌ | ❌ |
|
||||
| MQL5 compilation | ✅ | ❌ | ❌ |
|
||||
| macOS/Linux native | ✅ | Windows only | Cloud |
|
||||
| Optimization | ✅ Background | ❌ | ✅ Paid |
|
||||
**Focus:** Backtest organization, reporting, and analytics — capabilities MT5 itself doesn't provide.
|
||||
|
||||
## Quick Install
|
||||
| | MT5-Quant | Others |
|
||||
|---|---|---|
|
||||
| **Platform** | macOS/Linux native | Windows only |
|
||||
| **Backtest pipeline** | ✅ Full (compile → run → analyze) | ✅ Via MT5 Python package |
|
||||
| **Deal-level analytics** | ✅ 19 dimensions, DB-backed | ❌ |
|
||||
| **Report organization** | ✅ SQLite (reports + deals) + search + history | ❌ |
|
||||
| **MQL5 compilation** | ✅ Headless (MetaEditor via Wine, no GUI) | ⚠️ Via GUI or terminal |
|
||||
| **Optimization** | ✅ Background + results parsing + .set generation | ⚠️ Terminal only, no parsing |
|
||||
| **Crash debugging** | ✅ Wine/MT5 diagnostics | ❌ |
|
||||
|
||||
```bash
|
||||
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
|
||||
tar -xzf mt5.tar.gz
|
||||
bash scripts/setup.sh
|
||||
claude mcp add MT5-Quant -- $(pwd)/mt5-quant
|
||||
```
|
||||
|
||||
**[Full Setup →](docs/QUICKSTART.md)**
|
||||
**Others** typically run on Windows using the [MetaTrader5 Python package](https://pypi.org/project/MetaTrader5/), providing full terminal operations. MT5-Quant fills the gap: **organizing backtest reports, extracting deal-level insights, and managing optimization workflows** — none of which MT5 or its Python API expose natively.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install MT5-Quant
|
||||
|
||||
Ask your LLM coding platform to install and configure MT5-Quant:
|
||||
|
||||
> "Please install mt5-quant. It's a Rust-based MCP server for MT5 backtesting at https://github.com/masdevid/mt5-quant. Run its `scripts/setup.sh` to auto-detect Wine and MT5 paths, register the MCP server, and configure everything."
|
||||
|
||||
The LLM will:
|
||||
1. Download the pre-built binary (or `cargo build --release`)
|
||||
2. Run `scripts/setup.sh` to auto-detect Wine/MT5 and write config
|
||||
3. Register the MCP server with your coding platform
|
||||
|
||||
### First Backtest
|
||||
|
||||
```
|
||||
Run a backtest on MyEA from 2025.01.01 to 2025.03.31
|
||||
```
|
||||
|
||||
The AI runs the full pipeline: compile → clean cache → backtest → extract → analyze.
|
||||
|
||||
## Documentation
|
||||
|
||||
| Doc | Purpose |
|
||||
|-----|---------|
|
||||
| [QUICKSTART.md](docs/QUICKSTART.md) | Complete setup for macOS/Linux |
|
||||
| [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) |
|
||||
| [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 |
|
||||
| `list_jobs` | List all optimization jobs with status |
|
||||
| `analyze_report` | Read `analysis.json` from any report directory |
|
||||
| `compare_baseline` | Compare report vs baseline, return winner/loser verdict |
|
||||
| `compile_ea` | Compile MQL5 EA via MetaEditor |
|
||||
@@ -68,11 +69,12 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
|
||||
| `list_indicators` | List all indicators in MQL5/Indicators directory |
|
||||
| `list_scripts` | List all scripts in MQL5/Scripts directory |
|
||||
| `healthcheck` | Quick server health check |
|
||||
| `list_symbols` | List all available symbols in MT5 terminal |
|
||||
|
||||
### Granular Analytics (individual analysis)
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
|------|-----|
|
||||
| `analyze_monthly_pnl` | Monthly P/L breakdown only |
|
||||
| `analyze_drawdown_events` | Drawdown events and causes only |
|
||||
| `analyze_top_losses` | Worst losing deals only |
|
||||
@@ -84,46 +86,108 @@ 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 |
|
||||
|
||||
### Reports & logs
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
|------|-----|
|
||||
| `list_reports` | Compact table of all runs with key metrics — no full analysis needed |
|
||||
| `get_latest_report` | Get most recent report with optional equity chart |
|
||||
| `search_reports` | Find reports by EA, symbol, date range, or profit criteria |
|
||||
| `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) |
|
||||
| `promote_to_baseline` | Write a history entry or report to `baseline.json` for compare_baseline |
|
||||
|
||||
### History & baseline
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
|------|-----|
|
||||
| `archive_report` | Convert one report dir → compact JSON entry in `backtest_history.json`, optionally delete source |
|
||||
| `archive_all_reports` | Bulk-archive all report dirs then optionally delete them; keeps N newest safe |
|
||||
| `get_history` | Query history with filters (EA, symbol, verdict, profit, DD) and sort options |
|
||||
| `annotate_history` | Attach verdict / notes / tags to any history entry |
|
||||
| `promote_to_baseline` | Write a history entry or report to `baseline.json` for compare_baseline |
|
||||
|
||||
### Cache management
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
|------|-----|
|
||||
| `cache_status` | MT5 tester cache size breakdown by symbol — check before cleaning |
|
||||
| `clean_cache` | Delete tester cache files; supports per-symbol and `dry_run` |
|
||||
|
||||
### Pre-flight & Validation
|
||||
|
||||
| Tool | Description |
|
||||
|------|-----|
|
||||
| `get_active_account` | Get current MT5 account session (login, server, available symbols) |
|
||||
| `check_symbol_data_status` | Validate symbol has sufficient history data for date range |
|
||||
| `check_mt5_status` | Check if MT5 terminal is installed and ready |
|
||||
| `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 |
|
||||
| `check_update` | Check if a newer version of MT5-Quant is available |
|
||||
| `update` | Update MT5-Quant to latest release |
|
||||
|
||||
### 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 |
|
||||
|------|-------------|
|
||||
|------|-----|
|
||||
| `list_set_files` | All .set files in tester profiles dir with sweep stats and combination counts |
|
||||
| `read_set_file` | Parse UTF-16LE `.set` file → structured JSON params |
|
||||
| `write_set_file` | Write full params dict → UTF-16LE `.set` with `chmod 444` |
|
||||
@@ -133,25 +197,70 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
|
||||
### .set file — analysis & generation
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
|------|-----|
|
||||
| `describe_sweep` | Swept params, value counts, and total optimization combinations |
|
||||
| `diff_set_files` | Side-by-side diff of two `.set` files — only changed params returned |
|
||||
| `set_from_optimization` | Generate a clean backtest `.set` from `get_optimization_results` params; optionally narrow sweep |
|
||||
|
||||
### 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.
|
||||
Run `verify_setup` from your LLM first — it checks all paths and returns actionable hints.
|
||||
|
||||
For crashes or unexplained failures during backtest/compile/optimization:
|
||||
- `diagnose_wine` — Check Wine installation and prefix health
|
||||
- `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)**
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
MT5-Quant stands on the shoulders of exceptional open-source projects:
|
||||
|
||||
- **[Rust](https://www.rust-lang.org/)** — The language that makes zero-cost abstractions, memory safety, and fearless concurrency practical
|
||||
- **[Tokio](https://tokio.rs/)** — The async runtime powering all concurrent operations
|
||||
- **[Wine](https://www.winehq.org/)** — Making MT5 execution possible on macOS and Linux without Windows licensing
|
||||
- **[MetaTrader 5](https://www.metatrader5.com/)** — MetaQuotes' trading platform (trademark of MetaQuotes Software Corp.)
|
||||
- **[rusqlite](https://github.com/rusqlite/rusqlite)** — Ergonomic SQLite bindings for Rust
|
||||
- **[serde](https://serde.rs/)** — The serialization framework making config and report handling painless
|
||||
- **[scraper](https://github.com/causal-agent/scraper)** — HTML parsing for MT5 report extraction
|
||||
- **[tempfile](https://github.com/Stebalien/temp-file)** — Secure temporary file handling
|
||||
|
||||
Special thanks to the Model Context Protocol (MCP) team at Anthropic for defining the standard that makes AI-powered development workflows possible.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**Not Financial Advice.** MT5-Quant is a development and analysis tool for algorithmic trading strategies. It does not provide investment advice, trading recommendations, or guarantee profitability. All backtest results are historical simulations and do not guarantee future performance.
|
||||
|
||||
**Use at Your Own Risk.** Trading financial instruments carries substantial risk of loss. The authors and contributors of MT5-Quant accept no liability for:
|
||||
- Trading losses incurred using strategies developed or tested with this tool
|
||||
- Data loss or corruption from backtest operations
|
||||
- Bugs, errors, or incorrect analysis results
|
||||
- System crashes, Wine compatibility issues, or MT5 failures
|
||||
|
||||
**Software Warranty.** This software is provided "as-is" without warranty of any kind, express or implied, including but not limited to warranties of merchantability, fitness for a particular purpose, or non-infringement. See LICENSE for full terms.
|
||||
|
||||
**Third-Party Software.** MT5-Quant interacts with MetaTrader 5, Wine, and other third-party software. Users are responsible for complying with all applicable licenses and terms of service for these dependencies. MetaTrader is a trademark of MetaQuotes Software Corp. MT5-Quant is not affiliated with, endorsed by, or sponsored by MetaQuotes.
|
||||
|
||||
**Regulatory Compliance.** Users are responsible for ensuring their trading activities comply with applicable financial regulations in their jurisdiction. Automated trading may be restricted or require licensing in some regions.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
*Built from battle-tested production infrastructure. Every edge case in the pipeline was hit in production.*
|
||||
|
||||
+37
-67
@@ -1,14 +1,12 @@
|
||||
# Architecture Deep Dive
|
||||
# Architecture
|
||||
|
||||
## Design Philosophy
|
||||
## Design Principles
|
||||
|
||||
**Deal-level over aggregate.** MT5's built-in HTML report gives you: profit, profit factor, max DD%, trade count. That's it. You cannot tell from those numbers whether a drawdown was caused by overleveraged locking, a bad entry during a news spike, or a grid that reached L8 in a trending market.
|
||||
**Deal-level over aggregate.** MT5's HTML report gives you: profit, profit factor, max DD%, trade count. MT5-Quant extracts every individual deal — entry price, exit price, P/L, comment string — to reconstruct what happened during each loss event. Result: `analysis.json`, AI-readable and diffable between runs.
|
||||
|
||||
MT5-Quant extracts every individual deal — entry price, exit price, P/L, comment string — and reconstructs what was happening when each loss event occurred. The `analysis.json` artifact is the result: AI-readable, stable schema, diffable between runs.
|
||||
**Pipeline idempotency.** MT5 caches aggressively (`.ex5` binaries, `.set` files, `terminal.ini` flags). The pipeline invalidates all cache before every run to prevent stale results.
|
||||
|
||||
**Pipeline idempotency.** MT5 caches aggressively. Cached `.ex5` binaries, cached `.set` files, stale `terminal.ini` flags. The pipeline exists specifically to invalidate all of these before every run. A backtest result that came from a cached EA binary is wrong in a way that's impossible to detect without the cache clear step.
|
||||
|
||||
**Background isolation for optimization.** Genetic optimizations run 2-6 hours. Running them inside a parent process that can be killed (Claude task runner, SSH session, terminal) corrupts the MT5 optimization state. The only correct pattern is `nohup + disown` — fully detached from all parent process trees.
|
||||
**Background isolation.** Genetic optimizations run 2-6 hours. The `nohup + disown` pattern prevents corruption if the parent process (SSH, Claude runner) is killed.
|
||||
|
||||
---
|
||||
|
||||
@@ -25,24 +23,26 @@ MT5-Quant/
|
||||
│ │ ├── metrics.rs # Metrics parsing from HTML/XML
|
||||
│ │ └── report.rs # Report, PipelineMetadata, etc.
|
||||
│ ├── analytics/ # Report extraction & analysis (migrated from Python)
|
||||
│ │ ├── extract.rs # HTML/XML report parser → metrics.json + deals.csv
|
||||
│ │ ├── extract.rs # HTML/XML report parser → metrics.json (deals → DB)
|
||||
│ │ └── analyze.rs # Deal-level analysis engine → analysis.json
|
||||
│ ├── compile/ # MQL5 compilation
|
||||
│ │ └── mql_compiler.rs # MetaEditor wrapper (Wine/CrossOver)
|
||||
│ ├── pipeline/ # Backtest orchestration
|
||||
│ │ ├── backtest.rs # 5-stage pipeline (COMPILE→CLEAN→BACKTEST→EXTRACT→ANALYZE)
|
||||
│ │ └── stages.rs # Pipeline stage definitions
|
||||
│ ├── storage/ # SQLite persistence
|
||||
│ │ └── database.rs # ReportDb: reports table + deals table
|
||||
│ └── tools/ # MCP tool definitions
|
||||
│ ├── definitions/ # Tool schemas (9 domain modules, 43 tools)
|
||||
│ ├── definitions/ # Tool schemas (9 domain modules, 90 tools)
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── analytics.rs # 9 analysis tools
|
||||
│ │ ├── backtest.rs # 4 backtest tools
|
||||
│ │ ├── analytics.rs # 19 analysis tools (DB-backed)
|
||||
│ │ ├── backtest.rs # 7 backtest tools
|
||||
│ │ ├── baseline.rs # 1 baseline tool
|
||||
│ │ ├── experts.rs # 4 EA/indicator/script tools
|
||||
│ │ ├── experts.rs # 9 EA/indicator/script tools
|
||||
│ │ ├── optimization.rs # 4 optimization tools
|
||||
│ │ ├── reports.rs # 11 report management tools
|
||||
│ │ ├── reports.rs # 20 report management tools
|
||||
│ │ ├── setfiles.rs # 8 .set file tools
|
||||
│ │ └── system.rs # 3 system tools
|
||||
│ │ └── system.rs # 6 system tools
|
||||
│ └── handlers/ # Tool dispatch (9 domain modules)
|
||||
│ ├── mod.rs
|
||||
│ ├── analysis.rs
|
||||
@@ -59,11 +59,6 @@ MT5-Quant/
|
||||
│ ├── build-rust.sh # Rust build script
|
||||
│ └── optimize.sh # Genetic optimization launcher (nohup + disown)
|
||||
│
|
||||
├── analytics/ # Legacy Python (reference only)
|
||||
│ ├── extract.py
|
||||
│ ├── analyze.py
|
||||
│ └── optimize_parser.py
|
||||
│
|
||||
├── config/
|
||||
│ ├── mt5-quant.example.yaml # Template config
|
||||
│ └── mt5-quant.yaml # Live config (gitignored)
|
||||
@@ -152,17 +147,26 @@ MT5 runs in headless mode, writes the report, and exits.
|
||||
|
||||
---
|
||||
|
||||
### Stage 4: EXTRACT
|
||||
### Stage 4: EXTRACT + STORE
|
||||
|
||||
Single HTML/XML parse pass that produces three artifacts:
|
||||
Single HTML/XML parse pass. Deals go directly into the SQLite database; the raw report file is deleted afterwards.
|
||||
|
||||
```rust
|
||||
// src/analytics/extract.rs
|
||||
let extractor = ReportExtractor::new();
|
||||
let result = extractor.extract(&report_path, &output_dir)?;
|
||||
// → metrics.json (aggregate summary)
|
||||
// → deals.csv (all deals, 13 columns)
|
||||
// → deals.json (same data, JSON)
|
||||
// → metrics.json (aggregate summary — written to report_dir)
|
||||
// HTML report deleted after extraction
|
||||
|
||||
// src/storage/database.rs
|
||||
db.insert_deals(&report_id, &result.deals)?;
|
||||
// → deals table in SQLite (all deals, keyed by report_id)
|
||||
```
|
||||
|
||||
On-demand CSV export is available via the `export_deals_csv` tool:
|
||||
```
|
||||
export_deals_csv(report_id: "20260422_051041_DPS21_XAUUSDc_M5_1")
|
||||
// → report_dir/deals.csv (written only when explicitly requested)
|
||||
```
|
||||
|
||||
**Why single-pass?** MT5 HTML reports are large (1-5MB for 14-month tests). Each regex pass over the file takes ~200ms. The old pipeline ran 5 separate grep/regex passes. The Rust implementation uses a single-pass parser: 5× faster and no partial-read inconsistencies.
|
||||
@@ -182,12 +186,13 @@ if ext == "xml" || path.ends_with(".htm.xml") {
|
||||
}
|
||||
```
|
||||
|
||||
**Deal columns (13):**
|
||||
**Deal columns (stored in DB):**
|
||||
```
|
||||
Time | Type | Direction | Volume | Price | S/L | T/P | Profit | Balance | Comment | Order | Magic | Entry
|
||||
time | deal | symbol | deal_type | entry | volume | price | order_id
|
||||
commission | swap | profit | balance | comment | magic
|
||||
```
|
||||
|
||||
The `Comment` column is the key to grid analytics. The EA writes `"Layer #3"`, `"Locking Total"`, `"Zombie Exit"` etc. Pattern matching on comments reconstructs which position was at which layer.
|
||||
The `comment` column is the key to grid analytics. The EA writes `"Layer #3"`, `"Locking Total"`, `"Zombie Exit"` etc. Pattern matching on comments reconstructs which position was at which layer.
|
||||
|
||||
---
|
||||
|
||||
@@ -231,15 +236,6 @@ Built-in profiles:
|
||||
| `trend` | — | `magic` | 240 | breakeven, trailing, partial, tp, sl |
|
||||
| `hedge` | — | `magic+direction` | 120 | tp, sl, net_close, partial |
|
||||
|
||||
#### Entry points (after `pip install -e .`)
|
||||
|
||||
```bash
|
||||
mt5-analyze deals.csv # generic
|
||||
mt5-analyze-grid deals.csv # grid / martingale (default in pipeline)
|
||||
mt5-analyze-scalper deals.csv # scalper
|
||||
mt5-analyze-trend deals.csv # trend following
|
||||
mt5-analyze-hedge deals.csv # hedging
|
||||
```
|
||||
|
||||
#### Analytics functions
|
||||
|
||||
@@ -416,7 +412,7 @@ Then set `DISPLAY=:99` in MT5-Quant's environment config.
|
||||
|
||||
**Comment-based analytics:**
|
||||
- Strategy-specific analytics (depth histogram, exit reason, DD cause) depend on EA comment strings. EAs that don't write structured comments will get `generic` profile results — summary metrics, session breakdown, streaks, and direction bias all still work; only keyword-classified fields fall back to `"unknown"` or profit-sign.
|
||||
- Custom comment patterns can be supported by adding a new entry to `PROFILES` in `analytics/analyze.py` — no other code changes needed.
|
||||
- Custom comment patterns can be supported by adding a new entry to `PROFILES` in `src/analytics/analyze.rs`.
|
||||
|
||||
**Single MT5 instance:**
|
||||
- MT5 is single-instance per Windows drive. Two backtests cannot run simultaneously on the same Wine prefix. Parallelism requires multiple Wine prefixes (separate installations).
|
||||
@@ -425,34 +421,8 @@ Then set `DISPLAY=:99` in MT5-Quant's environment config.
|
||||
|
||||
## Claude Code Integration
|
||||
|
||||
`setup.sh --claude-code` generates two files that give Claude persistent context about the user's trading setup:
|
||||
|
||||
### `config/CLAUDE.template.md`
|
||||
|
||||
A project-level CLAUDE.md template the user copies to their EA project root. Encodes:
|
||||
`setup.sh` generates `config/CLAUDE.template.md` — a project-level template encoding:
|
||||
- MT5-Quant tool names and when to use them
|
||||
- Baseline tracking policy (never call something an improvement without comparing to `baseline.json`)
|
||||
- Symbol name reminder (broker-specific suffix matters — `XAUUSD.cent` ≠ `XAUUSD`)
|
||||
- Backtest and optimization constraints (model 0, single instance, UTF-16LE .set files)
|
||||
|
||||
### `.claude/hooks/user-prompt-submit.sh`
|
||||
|
||||
A Claude Code hook that runs before every prompt submission. Reads `config/baseline.json` and outputs a JSON context block:
|
||||
|
||||
```json
|
||||
{"context": "## Production Baseline (config/baseline.json)\n..."}
|
||||
```
|
||||
|
||||
Claude Code injects this into the system context for every conversation turn. The result: Claude always knows the current production metrics without the user having to paste them.
|
||||
|
||||
**Hook execution path:**
|
||||
```
|
||||
User types prompt
|
||||
→ user-prompt-submit.sh executes
|
||||
→ reads config/baseline.json
|
||||
→ outputs {"context": "..."} to stdout
|
||||
→ Claude Code prepends to system context
|
||||
→ Claude sees baseline in every prompt
|
||||
```
|
||||
|
||||
**Graceful degradation:** If `baseline.json` doesn't exist or is malformed, the hook exits 0 silently — no prompt is blocked. The baseline section simply doesn't appear until the user creates the file.
|
||||
- Baseline tracking policy (compare to `baseline.json` before calling improvements)
|
||||
- Symbol name reminders (`XAUUSD.cent` ≠ `XAUUSD`)
|
||||
- Backtest constraints (model 0, UTF-16LE .set files)
|
||||
|
||||
+4
-51
@@ -1,18 +1,15 @@
|
||||
# Configuration Reference
|
||||
|
||||
## Config File Location
|
||||
## Config File
|
||||
|
||||
```
|
||||
config/mt5-quant.yaml # Project directory (development)
|
||||
~/.config/mt5-quant/config/mt5-quant.yaml # System-wide (production)
|
||||
```
|
||||
|
||||
Environment variable to override:
|
||||
```bash
|
||||
export MT5_MCP_HOME=/path/to/config
|
||||
```
|
||||
Override with env: `export MT5_MCP_HOME=/path/to/config`
|
||||
|
||||
## Full Config Example
|
||||
## Full Example
|
||||
|
||||
```yaml
|
||||
# Required: Wine executable path
|
||||
@@ -52,53 +49,9 @@ optimization:
|
||||
min_agents: 4
|
||||
```
|
||||
|
||||
## Platform-Specific Examples
|
||||
|
||||
### macOS with MetaTrader 5.app
|
||||
|
||||
```yaml
|
||||
wine_executable: "/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"
|
||||
terminal_dir: "~/Library/Application Support/net.metaquotes.wine.metatrader5/drive_c/Program Files/MetaTrader 5"
|
||||
defaults:
|
||||
symbol: "XAUUSDc"
|
||||
timeframe: "M5"
|
||||
deposit: 10000
|
||||
```
|
||||
|
||||
### macOS with CrossOver
|
||||
|
||||
```yaml
|
||||
wine_executable: "/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine64"
|
||||
terminal_dir: "~/Library/Application Support/MetaQuotes/Terminal/<hash>/drive_c/Program Files/MetaTrader 5"
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
```yaml
|
||||
wine_executable: "/usr/bin/wine64"
|
||||
terminal_dir: "~/.wine/drive_c/Program Files/MetaTrader 5"
|
||||
display:
|
||||
mode: headless
|
||||
xvfb_display: ":99"
|
||||
```
|
||||
|
||||
## Headless Mode (Linux VPS)
|
||||
|
||||
```yaml
|
||||
display:
|
||||
mode: headless
|
||||
xvfb_display: ":99"
|
||||
xvfb_screen: "1024x768x16"
|
||||
```
|
||||
|
||||
Requires:
|
||||
```bash
|
||||
sudo apt install xvfb
|
||||
```
|
||||
|
||||
## Auto-Detection
|
||||
|
||||
`setup.sh` automatically detects:
|
||||
`setup.sh` detects:
|
||||
- Wine executable (MetaTrader 5.app, CrossOver, or system Wine)
|
||||
- MT5 terminal directory
|
||||
- Architecture (Apple Silicon adds `arch -x86_64`)
|
||||
|
||||
+2228
-12
File diff suppressed because it is too large
Load Diff
+18
-112
@@ -1,138 +1,44 @@
|
||||
# Quickstart Guide
|
||||
|
||||
## 1. Download or Build
|
||||
For end-users wanting full platform-specific steps, see the [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md).
|
||||
|
||||
### Option A: Prebuilt Binary (Recommended)
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# macOS (Apple Silicon)
|
||||
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
|
||||
tar -xzf mt5-quant.tar.gz
|
||||
Ask your LLM platform to install MT5-Quant:
|
||||
|
||||
# Linux (x64)
|
||||
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
|
||||
tar -xzf mt5-quant.tar.gz
|
||||
```
|
||||
> "Please install mt5-quant from https://github.com/masdevid/mt5-quant. Download the pre-built binary, run `scripts/setup.sh` to auto-detect Wine/MT5 paths, and register the MCP server."
|
||||
|
||||
### Option B: Build from Source
|
||||
The LLM will handle:
|
||||
1. Download binary or `cargo build --release`
|
||||
2. Run setup script to auto-detect Wine and MT5 paths
|
||||
3. Write `config/mt5-quant.yaml` (gitignored)
|
||||
4. Register MCP server with your platform
|
||||
|
||||
```bash
|
||||
git clone https://github.com/masdevid/mt5-mcp
|
||||
cd mt5-mcp
|
||||
bash scripts/build-rust.sh
|
||||
```
|
||||
## Minimal Config
|
||||
|
||||
## 2. Install MetaTrader 5
|
||||
If manual config is needed, `config/mt5-quant.yaml` requires only:
|
||||
|
||||
### macOS - MetaTrader 5.app (Free)
|
||||
|
||||
1. Download from [metatrader5.com](https://www.metatrader5.com/en/download)
|
||||
2. Install to `/Applications`
|
||||
3. **Launch once** to initialize Wine prefix (~30s), then quit
|
||||
|
||||
Auto-detected paths:
|
||||
- Wine: `/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64`
|
||||
- MT5: `~/Library/Application Support/net.metaquotes.wine.metatrader5/drive_c/Program Files/MetaTrader 5`
|
||||
|
||||
### macOS - CrossOver (Paid, Better Compatibility)
|
||||
|
||||
1. Install [CrossOver](https://www.codeweavers.com/)
|
||||
2. Create bottle `MetaTrader5`
|
||||
3. Install MT5 inside bottle
|
||||
|
||||
Auto-detected paths:
|
||||
- Wine: `/Applications/CrossOver.app/Contents/SharedSupport/CrossOver/bin/wine64`
|
||||
- MT5: `~/Library/Application Support/MetaQuotes/<hash>/drive_c/Program Files/MetaTrader 5`
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
# Debian/Ubuntu
|
||||
sudo apt install wine64 xvfb
|
||||
|
||||
# Fedora/RHEL
|
||||
sudo dnf install wine xorg-x11-server-Xvfb
|
||||
|
||||
# Install MT5
|
||||
wine64 MetaTrader5Setup.exe
|
||||
```
|
||||
|
||||
MT5 location: `~/.wine/drive_c/Program Files/MetaTrader 5`
|
||||
|
||||
## 3. Configure
|
||||
|
||||
Run the setup script to auto-detect paths:
|
||||
|
||||
```bash
|
||||
bash scripts/setup.sh # interactive
|
||||
bash scripts/setup.sh --yes # non-interactive (CI)
|
||||
```
|
||||
|
||||
This creates `config/mt5-quant.yaml` (gitignored).
|
||||
|
||||
Minimum config:
|
||||
```yaml
|
||||
wine_executable: "/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"
|
||||
terminal_dir: "~/Library/Application Support/net.metaquotes.wine.metatrader5/drive_c/Program Files/MetaTrader 5"
|
||||
wine_executable: "/path/to/wine64"
|
||||
terminal_dir: "/path/to/MetaTrader 5"
|
||||
```
|
||||
|
||||
## 4. Register MCP Server
|
||||
`setup.sh` auto-detects both.
|
||||
|
||||
### Claude Code
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
claude mcp add MT5-Quant -- /path/to/mt5-quant/target/release/mt5-quant
|
||||
```
|
||||
|
||||
Verify:
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
## 5. Verify Setup
|
||||
|
||||
```bash
|
||||
bash scripts/platform_detect.sh
|
||||
```
|
||||
|
||||
Or in Claude/Windsurf:
|
||||
```
|
||||
Run verify_setup
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Wine: /Applications/MetaTrader 5.app/.../wine64
|
||||
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
|
||||
Display: gui
|
||||
Arch: arch -x86_64
|
||||
```
|
||||
|
||||
## 6. Run First Backtest
|
||||
## Run Backtest
|
||||
|
||||
```
|
||||
Run a backtest on MyEA from 2025.01.01 to 2025.03.31
|
||||
```
|
||||
|
||||
The AI will:
|
||||
1. Verify setup
|
||||
2. Compile your EA
|
||||
3. Clean MT5 cache
|
||||
4. Run backtest
|
||||
5. Extract and analyze results
|
||||
6. Report key findings
|
||||
The AI runs: compile → clean → backtest → extract → analyze.
|
||||
|
||||
---
|
||||
|
||||
**Next:** See [TOOLS.md](TOOLS.md) for all 43 available tools.
|
||||
**Next:** See [TOOLS.md](docs/MCP_TOOLS.md) for all 89 tools.
|
||||
|
||||
+14
-95
@@ -11,14 +11,12 @@ MT5's distributed testing lets you farm optimization work across multiple machin
|
||||
**Mac (master)**
|
||||
- MetaTrader 5 installed via CrossOver or Wine
|
||||
- MT5-Quant configured and working for local backtests
|
||||
- Port 3000 open in macOS firewall (or whichever port MT5 uses — check below)
|
||||
- Port 3000 open in macOS firewall (or whichever port MT5 uses)
|
||||
|
||||
**Linux server (agents)**
|
||||
- Wine 7.0+ (or Wine Staging for better compatibility)
|
||||
- Wine 7.0+
|
||||
- `metatester64.exe` from an MT5 installation
|
||||
- Access to the same MT5 data files (tick history) as the master — or let agents download on first run
|
||||
|
||||
---
|
||||
- Access to the same MT5 data files (tick history) as the master
|
||||
|
||||
## Step 1: Find the MT5 agent port on Mac
|
||||
|
||||
@@ -31,7 +29,6 @@ ls ~/Library/Application\ Support/MetaQuotes/*/drive_c/Program\ Files/MetaTrader
|
||||
You'll see directories like:
|
||||
```
|
||||
Agent-127.0.0.1-3000/ ← local agents (loopback)
|
||||
Agent-127.0.0.1-3001/
|
||||
Agent-0.0.0.0-3000/ ← remote listener (if enabled)
|
||||
```
|
||||
|
||||
@@ -40,69 +37,23 @@ If you don't see `Agent-0.0.0.0-*` directories, enable remote agents in MT5:
|
||||
|
||||
Note the port number (default: 3000).
|
||||
|
||||
---
|
||||
|
||||
## Step 2: Open firewall on Mac
|
||||
|
||||
```bash
|
||||
# Check current firewall rules
|
||||
sudo pfctl -s rules
|
||||
|
||||
# Allow incoming on agent port (example: 3000)
|
||||
# Add to /etc/pf.conf or use macOS Firewall in System Settings
|
||||
```
|
||||
|
||||
Or use macOS System Settings → Privacy & Security → Firewall → Firewall Options → Add MetaTrader 5.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: Install Wine on Linux server
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo dpkg --add-architecture i386
|
||||
sudo apt update
|
||||
sudo apt install wine64 wine32
|
||||
|
||||
# Verify
|
||||
wine64 --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4: Copy `metatester64.exe` to Linux
|
||||
## Step 2: Copy `metatester64.exe` to Linux
|
||||
|
||||
From your Mac MT5 installation:
|
||||
```bash
|
||||
MAC_MT5="$HOME/Library/Application Support/MetaQuotes/Terminal/XXXXX/drive_c/Program Files/MetaTrader 5"
|
||||
MAC_MT5="$HOME/Library/Application Support/MetaQuotes/Terminal/XXX/ drive_c/Program Files/MetaTrader 5"
|
||||
|
||||
scp "${MAC_MT5}/metatester64.exe" user@linux-server:~/mt5agents/
|
||||
scp "${MAC_MT5}/metaeditor64.exe" user@linux-server:~/mt5agents/ # not required for agents
|
||||
```
|
||||
|
||||
Also copy the required DLLs (they're in the same directory):
|
||||
```bash
|
||||
scp "${MAC_MT5}"/*.dll user@linux-server:~/mt5agents/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5: Launch agents on Linux
|
||||
## Step 3: Launch agents on Linux
|
||||
|
||||
```bash
|
||||
# On the Linux server
|
||||
cd ~/mt5agents/
|
||||
|
||||
# Replace MAC_IP with your Mac's local IP address
|
||||
# Replace 8 with number of CPU cores - 1
|
||||
wine64 metatester64.exe /server:192.168.1.100:3000 /agents:8
|
||||
```
|
||||
|
||||
**What this does:**
|
||||
- Connects to your Mac's MT5 master at `192.168.1.100:3000`
|
||||
- Registers 8 worker agents
|
||||
- MT5 on Mac will show 8 new agents in the agent manager
|
||||
|
||||
**To run as a background service:**
|
||||
```bash
|
||||
nohup wine64 metatester64.exe /server:192.168.1.100:3000 /agents:8 \
|
||||
@@ -110,9 +61,7 @@ nohup wine64 metatester64.exe /server:192.168.1.100:3000 /agents:8 \
|
||||
disown
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6: Verify agents appear in MT5
|
||||
## Step 4: Verify agents appear in MT5
|
||||
|
||||
On your Mac, open MT5:
|
||||
**View → Strategy Tester → Agents tab**
|
||||
@@ -120,68 +69,38 @@ On your Mac, open MT5:
|
||||
You should see entries like:
|
||||
```
|
||||
Agent-192.168.1.200-3000 [Active]
|
||||
Agent-192.168.1.200-3001 [Active]
|
||||
...
|
||||
```
|
||||
|
||||
If agents appear as `[Inactive]`, check:
|
||||
1. Mac firewall is allowing incoming connections on the agent port
|
||||
2. Linux server can reach Mac IP: `ping 192.168.1.100`
|
||||
3. Port is open: `nc -zv 192.168.1.100 3000`
|
||||
## Step 5: Configure MT5-Quant for remote agents
|
||||
|
||||
---
|
||||
|
||||
## Step 7: Configure MT5-Quant for remote agents
|
||||
|
||||
In `config/MT5-Quant.yaml`:
|
||||
In `config/mt5-quant.yaml`:
|
||||
```yaml
|
||||
optimization:
|
||||
remote_agents:
|
||||
enabled: true
|
||||
check_agent_count: true # Verify remote agents are connected before launching
|
||||
min_agents: 4 # Require at least N agents before optimizing
|
||||
check_agent_count: true
|
||||
min_agents: 4
|
||||
```
|
||||
|
||||
MT5-Quant will log the agent count before launching optimization:
|
||||
```
|
||||
[optimize] Local agents: 9, Remote agents: 8, Total: 17
|
||||
[optimize] Estimated completion: ~105 minutes (17,640 passes at 17 agents)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tick Data Sync
|
||||
|
||||
Remote agents need tick history to replay trades. On first run, MT5 automatically downloads ticks from the broker for each symbol+period combination tested. This can take 10-30 minutes per symbol.
|
||||
|
||||
**Speed up first run:** Pre-populate the tick cache on the Linux server by copying from Mac:
|
||||
On first run, MT5 automatically downloads ticks from the broker. Pre-populate on Linux:
|
||||
|
||||
```bash
|
||||
# On Mac — find tick data location
|
||||
find ~/Library/Application\ Support/MetaQuotes -name "*.bin" | grep "XAUUSD"
|
||||
|
||||
# Typical path:
|
||||
# Terminal/XXXXX/drive_c/users/user/AppData/Roaming/MetaQuotes/Terminal/Common/Files/
|
||||
|
||||
# Copy to Linux
|
||||
scp -r "${TICK_DIR}" user@linux-server:~/mt5agents/ticks/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Agents connect then immediately disconnect**
|
||||
- MT5 version mismatch between `metatester64.exe` (from Mac) and the master. Use the exact same build number.
|
||||
- Check `~/mt5agents/agents.log` for Wine errors.
|
||||
|
||||
**Agents show as connected but don't receive work**
|
||||
- MT5 sometimes requires optimization to be started before assigning work to new agents.
|
||||
- Start an optimization from the MT5 GUI first to "activate" remote agents, then cancel it and use MT5-Quant.
|
||||
|
||||
**Performance is slower with remote agents**
|
||||
- Network latency between Mac and Linux server. Results are sent over TCP after each pass.
|
||||
- Local gigabit network: negligible. WiFi or WAN: significant overhead. Use wired connection.
|
||||
|
||||
**Linux server runs out of memory**
|
||||
- Each `metatester64.exe` instance uses ~200-400MB.
|
||||
- 8 agents = ~2-3GB RAM. Size agent count accordingly.
|
||||
- Use wired connection. WiFi or WAN: significant overhead.
|
||||
|
||||
+61
-3
@@ -17,12 +17,17 @@ If using CrossOver, confirm bottle is named `MetaTrader5`.
|
||||
|
||||
### Linux
|
||||
|
||||
Install Wine 7.0+ and confirm on PATH:
|
||||
```bash
|
||||
sudo apt install wine64 # Debian/Ubuntu
|
||||
sudo dnf install wine # Fedora/RHEL
|
||||
which wine64 # confirm on PATH
|
||||
# Debian/Ubuntu
|
||||
sudo apt install wine64
|
||||
# Fedora/RHEL
|
||||
sudo dnf install wine
|
||||
which wine64
|
||||
```
|
||||
|
||||
Ask your LLM platform to install Wine if it's missing.
|
||||
|
||||
## terminal64.exe Missing
|
||||
|
||||
MT5 unpacks `terminal64.exe` only after first launch.
|
||||
@@ -69,6 +74,42 @@ Set `MT5_MCP_HOME` or ensure config exists at:
|
||||
|
||||
4. **Date range has no trades** — try wider range or confirm symbol was active.
|
||||
|
||||
## Backtest Completes But Always Shows "journal-only" (No HTML Report)
|
||||
|
||||
**Symptom:** Every backtest produces `status: completed_no_html` and all deal profits are `0.0`.
|
||||
|
||||
**Cause:** On Wine/macOS, `ShutdownTerminal=1` does **not** cause `terminal64.exe` to exit after the
|
||||
test completes. The tester agent (MetaTester 5) closes normally, but the terminal process stays alive
|
||||
indefinitely. Without process exit, MT5 never writes the HTML report.
|
||||
|
||||
**How the pipeline handles this:** The inactivity watchdog detects that the tester log has stopped
|
||||
growing (test done), waits 30 seconds polling for the HTML file, then kills `terminal64.exe`
|
||||
unconditionally. If the HTML appears during the wait it is extracted; otherwise journal extraction
|
||||
provides deal counts and final balance (but no per-deal P&L).
|
||||
|
||||
**To maximise HTML report chances:** Pass `inactivity_kill_secs=120` explicitly (it is disabled by
|
||||
default). With `shutdown=true` (default) this gives MT5 120s of quiet time + 30s of HTML-wait before
|
||||
the kill.
|
||||
|
||||
```
|
||||
launch_backtest(expert: "MyEA", shutdown: true, inactivity_kill_secs: 120)
|
||||
```
|
||||
|
||||
**Fallback data available from journal:**
|
||||
- Deal count, volume, prices, timestamps ✓
|
||||
- Final balance (pips) ✓
|
||||
- Per-deal profit/loss ✗ (always 0.0)
|
||||
- Win rate, profit factor, Sharpe, drawdown ✗ (require HTML)
|
||||
|
||||
## `list_deals` Returns 0 Filtered Results
|
||||
|
||||
Analytics tools filter deals with `is_closed_trade()` which checks `entry = "out"`. If all deals
|
||||
show `entry = "in"`, the position tracker that infers direction from signed lot accumulation may
|
||||
not have run (old binary in memory).
|
||||
|
||||
**Fix:** Restart the MCP server (restart Claude Code / your IDE) after installing a new binary.
|
||||
The server process caches the binary in memory — `install` doesn't hot-reload it.
|
||||
|
||||
## MetaEditor Compile Errors
|
||||
|
||||
Check `<terminal_dir>/MQL5/Logs/`:
|
||||
@@ -82,6 +123,23 @@ Check `<terminal_dir>/MQL5/Logs/`:
|
||||
- Check `.set` file values appropriate for symbol/broker
|
||||
- Confirm `OnInit()` returns `INIT_SUCCEEDED` (MT5 Journal tab)
|
||||
|
||||
## Analytics Tools: "No reports in DB" or "Report not found"
|
||||
|
||||
Analytics tools load deals from the **SQLite database**, not from CSV files on disk. Resolution order:
|
||||
|
||||
1. `report_id` (preferred) — ID from `list_reports`
|
||||
2. `report_dir` (legacy) — filesystem path, looks up matching DB entry
|
||||
3. No args — uses the latest report automatically
|
||||
|
||||
Pre-DB reports (before this version) won't be found by `report_dir`. Re-run the backtest to get a DB-backed report.
|
||||
|
||||
**`deals.csv` is no longer written automatically.** Call `export_deals_csv` to generate one on demand:
|
||||
```
|
||||
export_deals_csv() # latest report → report_dir/deals.csv
|
||||
export_deals_csv(report_id: "20260422_…") # specific report
|
||||
export_deals_csv(output_path: "/tmp/out.csv") # custom path
|
||||
```
|
||||
|
||||
## Optimization Never Finishes
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# Windsurf MCP Integration Setup
|
||||
|
||||
## Quick Setup
|
||||
|
||||
### Option 1: Download Prebuilt Binary (Recommended)
|
||||
|
||||
```bash
|
||||
# macOS (Apple Silicon)
|
||||
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
|
||||
tar -xzf mt5-quant.tar.gz
|
||||
|
||||
# Linux (x64)
|
||||
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
|
||||
tar -xzf mt5-quant.tar.gz
|
||||
```
|
||||
|
||||
### Option 2: Build from Source
|
||||
|
||||
```bash
|
||||
bash scripts/build-rust.sh
|
||||
```
|
||||
|
||||
### 2. Configure Windsurf
|
||||
|
||||
Edit `~/.windsurf/config.yaml`:
|
||||
|
||||
```yaml
|
||||
mcpServers:
|
||||
mt5-quant:
|
||||
command: /Users/masdevid/jobs/mt5-quant/target/release/mt5-quant
|
||||
env:
|
||||
MT5_MCP_HOME: /Users/masdevid/jobs/mt5-quant
|
||||
```
|
||||
|
||||
### 3. Restart Windsurf
|
||||
Close and reopen Windsurf to load the MCP server.
|
||||
|
||||
### 4. Verify
|
||||
In Windsurf chat, test with:
|
||||
```
|
||||
Run verify_setup
|
||||
```
|
||||
|
||||
## Deployment to Multiple Machines
|
||||
|
||||
### Build for Distribution
|
||||
```bash
|
||||
# Build release binary
|
||||
cargo build --release
|
||||
|
||||
# Create tarball
|
||||
tar -czf mt5-quant-macos-arm64.tar.gz -C target/release mt5-quant
|
||||
|
||||
# Deploy to remote server
|
||||
scp mt5-quant-macos-arm64.tar.gz user@server:~/
|
||||
ssh user@server "tar -xzf mt5-quant-macos-arm64.tar.gz -C /opt/"
|
||||
ssh user@server "ln -s /opt/mt5-quant /usr/local/bin/"
|
||||
|
||||
# Copy config
|
||||
scp -r config/mt5-quant.yaml user@server:~/.config/mt5-quant/config/
|
||||
```
|
||||
|
||||
### Target Machine Requirements
|
||||
- MetaTrader 5 installed (via Wine/CrossOver)
|
||||
- Config file at `~/.config/mt5-quant/config/mt5-quant.yaml`
|
||||
- **NO Python required!**
|
||||
|
||||
### Windsurf Config on Target Machine
|
||||
```yaml
|
||||
mcpServers:
|
||||
mt5-quant:
|
||||
command: /usr/local/bin/mt5-quant
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### MCP server not appearing
|
||||
1. Check Windsurf logs: `~/.windsurf/logs/`
|
||||
2. Verify executable path is absolute
|
||||
3. Test executable manually: `./target/release/mt5-quant --help`
|
||||
|
||||
### Config not found
|
||||
Set `MT5_MCP_HOME` environment variable or ensure config is at default location:
|
||||
- macOS: `~/.config/mt5-quant/config/mt5-quant.yaml`
|
||||
|
||||
### Permission denied
|
||||
```bash
|
||||
chmod +x /path/to/mt5-quant
|
||||
```
|
||||
+33
-24
@@ -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 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..."
|
||||
cargo build --release
|
||||
# Build release binary
|
||||
echo "Building release binary..."
|
||||
RUSTFLAGS="-D warnings" cargo build --release
|
||||
echo ""
|
||||
|
||||
# Detect platform
|
||||
UNAME=$(uname -s)
|
||||
@@ -30,41 +34,46 @@ if [[ "$UNAME" == "Darwin" ]]; then
|
||||
elif [[ "$UNAME" == "Linux" ]]; then
|
||||
PLATFORM="linux-${ARCH}"
|
||||
else
|
||||
PLATFORM="unknown"
|
||||
PLATFORM="unknown-${ARCH}"
|
||||
fi
|
||||
|
||||
PACKAGE_NAME="mt5-quant-${PLATFORM}"
|
||||
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/WINDSURF_SETUP.md" "$PACKAGE_DIR/"
|
||||
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 "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 " ./mt5-quant --help"
|
||||
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
|
||||
|
||||
+5
-4
@@ -2,17 +2,18 @@
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.masdevid/mt5-quant",
|
||||
"title": "MT5-Quant",
|
||||
"description": "MCP server for MetaTrader 5 strategy development on macOS/Linux. 43 tools to compile MQL5 Expert Advisors, run backtests, analyze deals, and optimize parameters — no Windows required.",
|
||||
"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.0.0",
|
||||
"version": "1.33.0",
|
||||
"packages": [
|
||||
{
|
||||
"registryType": "mcpb",
|
||||
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.0.0/mt5-quant-macos-arm64.tar.gz",
|
||||
"fileSha256": "5d647e44efa32ab9a1b8e16139a3a0e4a58408ce5993cc0bf14d551184124fbb",
|
||||
"version": "1.33.0",
|
||||
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.33.0/mcp-mt5-quant-macos-arm64.tar.gz",
|
||||
"fileSha256": "54c6fd9d1f0d009bad1fa001582607377442ecfca908e3692b09d4c6b2732074",
|
||||
"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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+87
-64
@@ -15,7 +15,7 @@ impl ReportExtractor {
|
||||
|
||||
pub fn extract(&self, report_path: &str, output_dir: &str) -> Result<ExtractionResult> {
|
||||
let format = Self::detect_format(report_path);
|
||||
|
||||
|
||||
let (metrics, deals) = match format {
|
||||
ReportFormat::Xml => self.parse_xml(report_path)?,
|
||||
ReportFormat::Html => self.parse_html(report_path)?,
|
||||
@@ -24,22 +24,19 @@ impl ReportExtractor {
|
||||
fs::create_dir_all(output_dir)?;
|
||||
|
||||
let metrics_path = Path::new(output_dir).join("metrics.json");
|
||||
let deals_csv_path = Path::new(output_dir).join("deals.csv");
|
||||
let deals_json_path = Path::new(output_dir).join("deals.json");
|
||||
|
||||
self.write_metrics(&metrics, &metrics_path)?;
|
||||
self.write_deals_json(&deals, &deals_json_path)?;
|
||||
self.write_deals_csv(&deals, &deals_csv_path)?;
|
||||
|
||||
Ok(ExtractionResult {
|
||||
metrics,
|
||||
deals,
|
||||
metrics_path,
|
||||
deals_csv_path,
|
||||
deals_json_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_deals_to_csv(&self, deals: &[Deal], path: &Path) -> Result<()> {
|
||||
self.write_deals_csv(deals, path)
|
||||
}
|
||||
|
||||
fn detect_format(path: &str) -> ReportFormat {
|
||||
if path.ends_with(".xml") || path.ends_with(".htm.xml") {
|
||||
return ReportFormat::Xml;
|
||||
@@ -80,59 +77,96 @@ impl ReportExtractor {
|
||||
fn parse_deals_html(&self, text: &str) -> Result<Vec<Deal>> {
|
||||
let mut deals = Vec::new();
|
||||
|
||||
let re = regex::Regex::new(r"<tr[^>]*>.*?Deal.*?Time.*?Type.*?Direction.*?</tr>(.*)")
|
||||
.map_err(|e| anyhow!("Regex error: {}", e))?;
|
||||
// (?s) = dotall: makes '.' match '\n' so the regex works on multiline HTML.
|
||||
// MT5 HTML column order: Time | Deal | Symbol | Type | Direction |
|
||||
// Volume | Price | Order | Commission | Swap | Profit | Balance | Comment
|
||||
//
|
||||
// Strategy: locate the deals table by finding the header <tr> that contains
|
||||
// the column names, then parse every subsequent <tr> as a data row.
|
||||
// We try two header patterns to handle different MT5 versions/locales.
|
||||
|
||||
if let Some(captures) = re.captures(text) {
|
||||
let section = captures.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
let row_re = regex::Regex::new(r"(?s)<tr[^>]*>(.*?)</tr>")
|
||||
.map_err(|e| anyhow!("Row regex error: {}", e))?;
|
||||
let cell_re = regex::Regex::new(r"(?s)<td[^>]*>(.*?)</td>")
|
||||
.map_err(|e| anyhow!("Cell regex error: {}", e))?;
|
||||
|
||||
let row_re = regex::Regex::new(r"<tr[^>]*>(.*?)</tr>")
|
||||
.map_err(|e| anyhow!("Regex error: {}", e))?;
|
||||
// Collect all <tr> blocks once, then find the deals header and parse from there.
|
||||
let rows: Vec<&str> = row_re.captures_iter(text)
|
||||
.filter_map(|cap| cap.get(0).map(|m| m.as_str()))
|
||||
.collect();
|
||||
|
||||
for row_caps in row_re.captures_iter(section) {
|
||||
let row = row_caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
|
||||
let cell_re = regex::Regex::new(r"<td[^>]*>(.*?)</td>")
|
||||
.map_err(|e| anyhow!("Regex error: {}", e))?;
|
||||
|
||||
let cells: Vec<String> = cell_re.captures_iter(row)
|
||||
.filter_map(|cap| cap.get(1))
|
||||
.map(|m| Self::strip_tags(m.as_str()))
|
||||
.map(|s| s.replace(',', ""))
|
||||
.collect();
|
||||
// Find the header row index: it must contain both "Deal" and "Symbol" (case-insensitive).
|
||||
let header_idx = rows.iter().position(|row| {
|
||||
let lower = row.to_lowercase();
|
||||
(lower.contains(">deal<") || lower.contains(">deal </")) &&
|
||||
(lower.contains(">symbol<") || lower.contains(">symbol </"))
|
||||
});
|
||||
|
||||
if cells.len() < 3 || cells[0].is_empty() {
|
||||
continue;
|
||||
let start_idx = match header_idx {
|
||||
Some(i) => i + 1,
|
||||
None => {
|
||||
// Fallback: look for any row containing Time+Volume+Profit headers
|
||||
let alt = rows.iter().position(|row| {
|
||||
let lower = row.to_lowercase();
|
||||
lower.contains(">time<") && lower.contains(">volume<") && lower.contains(">profit<")
|
||||
});
|
||||
match alt {
|
||||
Some(i) => i + 1,
|
||||
None => {
|
||||
tracing::warn!("parse_deals_html: no deals table header found");
|
||||
return Ok(deals);
|
||||
}
|
||||
}
|
||||
|
||||
if cells.iter().take(5).any(|c| {
|
||||
let c_lower = c.trim().to_lowercase();
|
||||
c_lower == "balance" || c_lower == "credit"
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let deal = Deal {
|
||||
time: cells.get(0).cloned().unwrap_or_default(),
|
||||
deal: cells.get(1).cloned().unwrap_or_default(),
|
||||
symbol: cells.get(2).cloned().unwrap_or_default(),
|
||||
deal_type: cells.get(3).cloned().unwrap_or_default(),
|
||||
entry: cells.get(4).cloned().unwrap_or_default(),
|
||||
volume: cells.get(5).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
price: cells.get(6).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
order: cells.get(7).cloned().unwrap_or_default(),
|
||||
commission: cells.get(8).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
swap: cells.get(9).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
profit: cells.get(10).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
balance: cells.get(11).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
comment: cells.get(12).cloned().unwrap_or_default(),
|
||||
magic: cells.get(13).cloned(),
|
||||
};
|
||||
|
||||
deals.push(deal);
|
||||
}
|
||||
};
|
||||
|
||||
for row in &rows[start_idx..] {
|
||||
let cells: Vec<String> = cell_re.captures_iter(row)
|
||||
.filter_map(|cap| cap.get(1))
|
||||
.map(|m| Self::strip_tags(m.as_str()))
|
||||
.map(|s| s.replace(',', ""))
|
||||
.collect();
|
||||
|
||||
if cells.len() < 3 || cells[0].trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip balance/credit operation rows (Type column is index 3 in MT5 HTML)
|
||||
let type_cell = cells.get(3).map(|s| s.trim().to_lowercase()).unwrap_or_default();
|
||||
if type_cell == "balance" || type_cell == "credit" {
|
||||
continue;
|
||||
}
|
||||
// Also skip sub-header rows that repeat column names
|
||||
if type_cell == "type" || cells.get(1).map(|s| s.trim().to_lowercase()).as_deref() == Some("deal") {
|
||||
continue;
|
||||
}
|
||||
// Skip rows with no deal number (e.g. totals row)
|
||||
let deal_num = cells.get(1).map(|s| s.trim().to_string()).unwrap_or_default();
|
||||
if deal_num.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let deal = Deal {
|
||||
time: cells.get(0).cloned().unwrap_or_default(),
|
||||
deal: deal_num,
|
||||
symbol: cells.get(2).cloned().unwrap_or_default(),
|
||||
deal_type: cells.get(3).cloned().unwrap_or_default(),
|
||||
entry: cells.get(4).cloned().unwrap_or_default(),
|
||||
volume: cells.get(5).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
price: cells.get(6).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
order: cells.get(7).cloned().unwrap_or_default(),
|
||||
commission: cells.get(8).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
swap: cells.get(9).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
profit: cells.get(10).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
balance: cells.get(11).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
comment: cells.get(12).cloned().unwrap_or_default(),
|
||||
magic: cells.get(13).cloned(),
|
||||
};
|
||||
|
||||
deals.push(deal);
|
||||
}
|
||||
|
||||
tracing::info!("parse_deals_html: extracted {} deals", deals.len());
|
||||
Ok(deals)
|
||||
}
|
||||
|
||||
@@ -215,13 +249,6 @@ impl ReportExtractor {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_deals_json(&self, deals: &[Deal], path: &Path) -> Result<()> {
|
||||
let json = serde_json::to_string_pretty(deals)?;
|
||||
let mut file = File::create(path)?;
|
||||
file.write_all(json.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_deals_csv(&self, deals: &[Deal], path: &Path) -> Result<()> {
|
||||
let mut file = File::create(path)?;
|
||||
writeln!(file, "time,deal,symbol,type,entry,volume,price,order,commission,swap,profit,balance,comment")?;
|
||||
@@ -279,10 +306,6 @@ pub struct ExtractionResult {
|
||||
pub deals: Vec<Deal>,
|
||||
#[allow(dead_code)]
|
||||
pub metrics_path: PathBuf,
|
||||
#[allow(dead_code)]
|
||||
pub deals_csv_path: PathBuf,
|
||||
#[allow(dead_code)]
|
||||
pub deals_json_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
||||
+52
-12
@@ -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");
|
||||
@@ -85,8 +90,8 @@ impl MqlCompiler {
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let ex5_path = staged_mq5.with_extension("ex5");
|
||||
if !ex5_path.exists() {
|
||||
let staged_ex5 = staged_mq5.with_extension("ex5");
|
||||
if !staged_ex5.exists() {
|
||||
let final_errors = if errors.is_empty() {
|
||||
vec![format!("Compilation failed. Log:\n{}", &log_text[log_text.len().saturating_sub(500)..])]
|
||||
} else {
|
||||
@@ -102,11 +107,37 @@ impl MqlCompiler {
|
||||
});
|
||||
}
|
||||
|
||||
let binary_size = fs::metadata(&ex5_path)?.len();
|
||||
let binary_size = fs::metadata(&staged_ex5)?.len();
|
||||
|
||||
// Deploy compiled output to the real MQL5/Experts/{ea_name}/ directory so
|
||||
// MT5 can actually load it. The temp dir is only needed to avoid Wine path
|
||||
// issues with spaces; the real experts dir is the authoritative location.
|
||||
let final_ex5_path = if let Some(experts_dir) = self.config.experts_dir.as_ref() {
|
||||
let real_experts = PathBuf::from(experts_dir);
|
||||
let real_ea_dir = real_experts.join(ea_name);
|
||||
fs::create_dir_all(&real_ea_dir)?;
|
||||
|
||||
// Sync source files (.mq5 + .mqh) into the real experts dir so that
|
||||
// future compiles from the experts dir also work correctly.
|
||||
if let Err(e) = self.sync_project_to_experts(source_path, &real_experts) {
|
||||
tracing::warn!("Could not sync source to experts dir: {}", e);
|
||||
}
|
||||
|
||||
// Copy the compiled .ex5 to the real experts dir.
|
||||
let dest_ex5 = real_ea_dir.join(format!("{}.ex5", ea_name));
|
||||
fs::copy(&staged_ex5, &dest_ex5)?;
|
||||
tracing::info!("Deployed {} → {}", staged_ex5.display(), dest_ex5.display());
|
||||
dest_ex5
|
||||
} else {
|
||||
// No experts_dir configured — fall back to the staged path so callers
|
||||
// still get a valid path even if MT5 won't find it automatically.
|
||||
tracing::warn!("experts_dir not configured; .ex5 left in staging dir");
|
||||
staged_ex5
|
||||
};
|
||||
|
||||
Ok(CompileResult {
|
||||
success: errors.is_empty(),
|
||||
ex5_path: Some(ex5_path),
|
||||
ex5_path: Some(final_ex5_path),
|
||||
errors,
|
||||
warnings,
|
||||
binary_size,
|
||||
@@ -197,15 +228,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 +274,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(())
|
||||
}
|
||||
|
||||
-141
@@ -1,141 +0,0 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub wine_executable: Option<String>,
|
||||
pub terminal_dir: Option<String>,
|
||||
pub experts_dir: Option<String>,
|
||||
pub tester_profiles_dir: Option<String>,
|
||||
pub tester_cache_dir: Option<String>,
|
||||
pub display_mode: Option<String>,
|
||||
pub backtest_symbol: Option<String>,
|
||||
pub backtest_deposit: Option<u32>,
|
||||
pub backtest_currency: Option<String>,
|
||||
pub backtest_leverage: Option<u32>,
|
||||
pub backtest_model: Option<u32>,
|
||||
pub backtest_timeframe: Option<String>,
|
||||
pub backtest_timeout: Option<u32>,
|
||||
pub opt_log_dir: Option<String>,
|
||||
pub opt_min_agents: Option<u32>,
|
||||
pub reports_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wine_executable: None,
|
||||
terminal_dir: None,
|
||||
experts_dir: None,
|
||||
tester_profiles_dir: None,
|
||||
tester_cache_dir: None,
|
||||
display_mode: None,
|
||||
backtest_symbol: None,
|
||||
backtest_deposit: None,
|
||||
backtest_currency: None,
|
||||
backtest_leverage: None,
|
||||
backtest_model: None,
|
||||
backtest_timeframe: None,
|
||||
backtest_timeout: None,
|
||||
opt_log_dir: None,
|
||||
opt_min_agents: None,
|
||||
reports_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let config_path = Self::get_config_path();
|
||||
if !config_path.exists() {
|
||||
return Ok(Config::default());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)?;
|
||||
let mut config: HashMap<String, String> = HashMap::new();
|
||||
|
||||
// Parse simple YAML format (key: value)
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') || !line.contains(':') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
let key = key.trim().to_string();
|
||||
let value = value.trim()
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.to_string();
|
||||
|
||||
if !value.is_empty() && value != "null" && value != "~" {
|
||||
config.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Config {
|
||||
wine_executable: config.get("wine_executable").cloned(),
|
||||
terminal_dir: config.get("terminal_dir").cloned(),
|
||||
experts_dir: config.get("experts_dir").cloned(),
|
||||
tester_profiles_dir: config.get("tester_profiles_dir").cloned(),
|
||||
tester_cache_dir: config.get("tester_cache_dir").cloned(),
|
||||
display_mode: config.get("display_mode").cloned(),
|
||||
backtest_symbol: config.get("backtest_symbol").cloned(),
|
||||
backtest_deposit: config.get("backtest_deposit").and_then(|s| s.parse().ok()),
|
||||
backtest_currency: config.get("backtest_currency").cloned(),
|
||||
backtest_leverage: config.get("backtest_leverage").and_then(|s| s.parse().ok()),
|
||||
backtest_model: config.get("backtest_model").and_then(|s| s.parse().ok()),
|
||||
backtest_timeframe: config.get("backtest_timeframe").cloned(),
|
||||
backtest_timeout: config.get("backtest_timeout").and_then(|s| s.parse().ok()),
|
||||
opt_log_dir: config.get("opt_log_dir").cloned(),
|
||||
opt_min_agents: config.get("opt_min_agents").and_then(|s| s.parse().ok()),
|
||||
reports_dir: config.get("reports_dir").cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_config_path() -> std::path::PathBuf {
|
||||
if let Ok(home) = std::env::var("MT5_MCP_HOME") {
|
||||
Path::new(&home).join("config").join("mt5-quant.yaml")
|
||||
} else {
|
||||
let base_path = dirs::home_dir()
|
||||
.unwrap_or_else(|| Path::new(".").to_path_buf())
|
||||
.join(".config")
|
||||
.join("mt5-quant");
|
||||
|
||||
if base_path.join("config").join("mt5-quant.yaml").exists() {
|
||||
base_path.join("config").join("mt5-quant.yaml")
|
||||
} else {
|
||||
// Development mode - use parent directory
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap_or(Path::new(".")).join("config").join("mt5-quant.yaml")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> String {
|
||||
match key {
|
||||
"wine_executable" => self.wine_executable.clone().unwrap_or_default(),
|
||||
"terminal_dir" => self.terminal_dir.clone().unwrap_or_default(),
|
||||
"experts_dir" => self.experts_dir.clone().unwrap_or_default(),
|
||||
"tester_profiles_dir" => self.tester_profiles_dir.clone().unwrap_or_default(),
|
||||
"tester_cache_dir" => self.tester_cache_dir.clone().unwrap_or_default(),
|
||||
"display_mode" => self.display_mode.clone().unwrap_or_else(|| "auto".to_string()),
|
||||
"backtest_symbol" => self.backtest_symbol.clone().unwrap_or_default(),
|
||||
"backtest_deposit" => self.backtest_deposit.unwrap_or(10000).to_string(),
|
||||
"backtest_currency" => self.backtest_currency.clone().unwrap_or_else(|| "USD".to_string()),
|
||||
"backtest_leverage" => self.backtest_leverage.unwrap_or(500).to_string(),
|
||||
"backtest_model" => self.backtest_model.unwrap_or(0).to_string(),
|
||||
"backtest_timeframe" => self.backtest_timeframe.clone().unwrap_or_else(|| "M5".to_string()),
|
||||
"backtest_timeout" => self.backtest_timeout.unwrap_or(900).to_string(),
|
||||
"opt_log_dir" => self.opt_log_dir.clone().unwrap_or_else(|| "/tmp".to_string()),
|
||||
"opt_min_agents" => self.opt_min_agents.unwrap_or(1).to_string(),
|
||||
"reports_dir" => self.reports_dir.clone().unwrap_or_else(|| "reports".to_string()),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+110
-2
@@ -6,17 +6,20 @@ mod pipeline;
|
||||
mod storage;
|
||||
mod tools;
|
||||
|
||||
mod config;
|
||||
mod mcp_server;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use serde_json::{json, Value};
|
||||
use std::io::{stdout, Write};
|
||||
use std::time::Instant;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::{info, error};
|
||||
|
||||
use crate::models::Config;
|
||||
use crate::pipeline::backtest::{BacktestPipeline, BacktestParams};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "mt5-quant")]
|
||||
#[command(about = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP")]
|
||||
@@ -28,6 +31,18 @@ struct Cli {
|
||||
/// Run on TCP port for debugging
|
||||
#[arg(short, long)]
|
||||
port: Option<u16>,
|
||||
|
||||
/// Test backtest launch performance (direct Rust call, not MCP)
|
||||
#[arg(long)]
|
||||
test_launch: bool,
|
||||
|
||||
/// EA name for test launch
|
||||
#[arg(long)]
|
||||
ea: Option<String>,
|
||||
|
||||
/// Startup delay for test launch (default: 10)
|
||||
#[arg(long)]
|
||||
startup_delay: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, serde::Serialize)]
|
||||
@@ -41,8 +56,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>,
|
||||
}
|
||||
|
||||
@@ -50,6 +68,7 @@ pub struct McpResponse {
|
||||
pub struct McpError {
|
||||
code: i32,
|
||||
message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
data: Option<Value>,
|
||||
}
|
||||
|
||||
@@ -79,10 +98,17 @@ 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();
|
||||
|
||||
if cli.test_launch {
|
||||
run_test_launch(cli.ea, cli.startup_delay).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(port) = cli.port {
|
||||
run_tcp_server(port).await?;
|
||||
} else {
|
||||
@@ -92,10 +118,83 @@ async fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_test_launch(ea: Option<String>, startup_delay: Option<u64>) -> Result<()> {
|
||||
let expert = ea.ok_or_else(|| anyhow::anyhow!("--ea is required for test launch"))?;
|
||||
let delay = startup_delay.unwrap_or(10);
|
||||
|
||||
println!("Testing MT5 backtest launch optimizations...");
|
||||
println!("==============================================");
|
||||
println!("EA: {}", expert);
|
||||
println!("Startup delay: {}s", delay);
|
||||
|
||||
let config = Config::load()?;
|
||||
|
||||
let params = BacktestParams {
|
||||
expert: expert.clone(),
|
||||
symbol: "XAUUSD".to_string(),
|
||||
from_date: "2024.01.01".to_string(),
|
||||
to_date: "2024.01.31".to_string(),
|
||||
timeframe: "M5".to_string(),
|
||||
deposit: 10000,
|
||||
model: 0,
|
||||
leverage: 500,
|
||||
set_file: None,
|
||||
skip_compile: true,
|
||||
skip_clean: false,
|
||||
skip_analyze: true,
|
||||
deep_analyze: false,
|
||||
shutdown: false,
|
||||
kill_existing: false,
|
||||
timeout: 900,
|
||||
gui: false,
|
||||
startup_delay_secs: delay,
|
||||
inactivity_kill_secs: None,
|
||||
};
|
||||
|
||||
let pipeline = BacktestPipeline::new(config);
|
||||
|
||||
println!("\nLaunching backtest...");
|
||||
let start = Instant::now();
|
||||
match pipeline.launch_backtest(params).await {
|
||||
Ok(job) => {
|
||||
let elapsed = start.elapsed();
|
||||
println!("✓ Launch completed in {:.2}s", elapsed.as_secs_f64());
|
||||
println!(" Report ID: {}", job.report_id);
|
||||
println!(" Report dir: {}", job.report_dir);
|
||||
println!("\nUse get_backtest_status to monitor progress.");
|
||||
}
|
||||
Err(e) => {
|
||||
let elapsed = start.elapsed();
|
||||
println!("✗ Launch failed after {:.2}s: {}", elapsed.as_secs_f64(), e);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n==============================================");
|
||||
println!("Test complete.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_stdio_server() -> Result<()> {
|
||||
info!("Starting MT5-Quant MCP server on stdio");
|
||||
|
||||
let server = std::sync::Arc::new(mcp_server::McpServer::new());
|
||||
let (notification_tx, mut notification_rx) = tokio::sync::mpsc::unbounded_channel::<mcp_server::Notification>();
|
||||
server.set_notification_sender(notification_tx).await;
|
||||
|
||||
// Spawn notification sender task
|
||||
tokio::spawn(async move {
|
||||
while let Some(notification) = notification_rx.recv().await {
|
||||
let notification_json = json!({
|
||||
"jsonrpc": "2.0",
|
||||
"method": notification.method,
|
||||
"params": notification.params,
|
||||
});
|
||||
println!("{}", notification_json);
|
||||
let _ = stdout().flush();
|
||||
}
|
||||
});
|
||||
|
||||
let mut reader = BufReader::new(tokio::io::stdin());
|
||||
let mut line = String::new();
|
||||
|
||||
@@ -112,6 +211,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);
|
||||
@@ -181,6 +285,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?;
|
||||
|
||||
+175
-22
@@ -1,13 +1,32 @@
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use crate::{models::Config as ModelsConfig, tools::ToolHandler, McpError, McpRequest, McpResponse};
|
||||
|
||||
#[derive(Debug)]
|
||||
#[allow(dead_code)]
|
||||
type NotificationCallback = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
|
||||
|
||||
/// Auto-verify result stored after first initialization
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(dead_code)]
|
||||
struct AutoVerifyResult {
|
||||
all_ok: bool,
|
||||
hint: String,
|
||||
config_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Notification {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
pub struct McpServer {
|
||||
initialized: Arc<Mutex<bool>>,
|
||||
tool_handler: Arc<ToolHandler>,
|
||||
tool_handler: Arc<Mutex<Option<ToolHandler>>>,
|
||||
auto_verify_result: Arc<Mutex<Option<AutoVerifyResult>>>,
|
||||
notification_tx: Arc<Mutex<Option<mpsc::UnboundedSender<Notification>>>>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
@@ -15,7 +34,116 @@ impl McpServer {
|
||||
let config = ModelsConfig::load().unwrap_or_default();
|
||||
Self {
|
||||
initialized: Arc::new(Mutex::new(false)),
|
||||
tool_handler: Arc::new(ToolHandler::new(config)),
|
||||
tool_handler: Arc::new(Mutex::new(Some(ToolHandler::new(config)))),
|
||||
auto_verify_result: Arc::new(Mutex::new(None)),
|
||||
notification_tx: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn set_notification_sender(&self, tx: mpsc::UnboundedSender<Notification>) {
|
||||
let mut guard = self.notification_tx.lock().await;
|
||||
*guard = Some(tx.clone());
|
||||
|
||||
// Update tool handler with notification callback
|
||||
let tx_clone = tx.clone();
|
||||
let callback = Arc::new(move |method: &str, params: serde_json::Value| {
|
||||
let _ = tx_clone.send(Notification {
|
||||
method: method.to_string(),
|
||||
params,
|
||||
});
|
||||
});
|
||||
|
||||
let config = ModelsConfig::load().unwrap_or_default();
|
||||
let new_handler = ToolHandler::with_notification_callback(config, callback);
|
||||
|
||||
let mut handler_guard = self.tool_handler.lock().await;
|
||||
*handler_guard = Some(new_handler);
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_notification_sender(&self) -> Option<mpsc::UnboundedSender<Notification>> {
|
||||
let guard = self.notification_tx.lock().await;
|
||||
guard.clone()
|
||||
}
|
||||
|
||||
/// Run verify_setup in background - non blocking
|
||||
fn spawn_auto_verify(&self) {
|
||||
let result_arc = self.auto_verify_result.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Get config
|
||||
let config = ModelsConfig::load().unwrap_or_default();
|
||||
let config_path = ModelsConfig::writable_config_path();
|
||||
|
||||
// Quick async file checks
|
||||
let config_exists = tokio::task::spawn_blocking({
|
||||
let path = config_path.clone();
|
||||
move || path.exists()
|
||||
}).await.unwrap_or(false);
|
||||
|
||||
let wine_ok = if let Some(wine) = &config.wine_executable {
|
||||
let wine = wine.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
std::path::Path::new(&wine).exists()
|
||||
}).await.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let term_ok = if let Some(term) = &config.terminal_dir {
|
||||
let term = term.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
std::path::Path::new(&term).is_dir()
|
||||
}).await.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let all_ok = config_exists && wine_ok && term_ok;
|
||||
|
||||
let hint = if all_ok {
|
||||
"Environment fully configured and ready".to_string()
|
||||
} else if !config_exists {
|
||||
format!("Auto-discovery will run on first request. Config will be written to {}", config_path.display())
|
||||
} else if !wine_ok {
|
||||
"Wine/CrossOver not found - required for MT5 execution".to_string()
|
||||
} else if !term_ok {
|
||||
"MT5 directory not found - check installation".to_string()
|
||||
} else {
|
||||
"Fix missing paths in config".to_string()
|
||||
};
|
||||
|
||||
let result = AutoVerifyResult {
|
||||
all_ok,
|
||||
hint,
|
||||
config_path: config_path.to_string_lossy().to_string(),
|
||||
};
|
||||
|
||||
// Store result
|
||||
let mut guard = result_arc.lock().await;
|
||||
*guard = Some(result);
|
||||
});
|
||||
}
|
||||
|
||||
/// Get current verify status (may be loading if called immediately after init)
|
||||
#[allow(dead_code)]
|
||||
async fn get_verify_status(&self) -> (Option<bool>, String) {
|
||||
let guard = self.auto_verify_result.lock().await;
|
||||
match guard.as_ref() {
|
||||
Some(result) => (Some(result.all_ok), result.hint.clone()),
|
||||
None => (None, "Checking environment...".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,22 +163,32 @@ impl McpServer {
|
||||
"2024-11-05"
|
||||
};
|
||||
|
||||
// Start background verify (non-blocking)
|
||||
self.spawn_auto_verify();
|
||||
|
||||
*self.initialized.lock().await = true;
|
||||
|
||||
// Return immediately with fast status
|
||||
let server_info = json!({
|
||||
"name": "MT5-Quant",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"setup": {
|
||||
"hint": "Auto-verification running... Use verify_setup tool for detailed status",
|
||||
}
|
||||
});
|
||||
|
||||
McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: request.id,
|
||||
result: Some(json!(crate::InitializeResult {
|
||||
protocol_version: negotiated_version.to_string(),
|
||||
capabilities: crate::ServerCapabilities {
|
||||
experimental: json!({}),
|
||||
tools: crate::ToolCapabilities {
|
||||
list_changed: false,
|
||||
result: Some(json!({
|
||||
"protocolVersion": negotiated_version,
|
||||
"capabilities": {
|
||||
"experimental": {},
|
||||
"tools": {
|
||||
"listChanged": false,
|
||||
},
|
||||
},
|
||||
server_info: crate::ServerInfo {
|
||||
name: "MT5-Quant".to_string(),
|
||||
version: "1.27.0".to_string(),
|
||||
},
|
||||
"serverInfo": server_info,
|
||||
})),
|
||||
error: None,
|
||||
}
|
||||
@@ -73,7 +211,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,
|
||||
}
|
||||
}
|
||||
@@ -145,12 +283,27 @@ impl McpServer {
|
||||
}
|
||||
|
||||
async fn handle_tool_call(&self, tool_name: &str, arguments: &Value) -> Value {
|
||||
self.tool_handler.handle(tool_name, arguments).await.unwrap_or_else(|e| json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Tool execution failed: {}", e)
|
||||
}],
|
||||
"isError": true
|
||||
}))
|
||||
let handler_guard = self.tool_handler.lock().await;
|
||||
let handler = handler_guard.as_ref().cloned();
|
||||
drop(handler_guard);
|
||||
|
||||
match handler {
|
||||
Some(h) => {
|
||||
h.handle(tool_name, arguments).await.unwrap_or_else(|e| json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Tool execution failed: {}", e)
|
||||
}],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
None => json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": "Tool handler not initialized"
|
||||
}],
|
||||
"isError": true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+298
-36
@@ -4,6 +4,77 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Active MT5 account session info
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CurrentAccount {
|
||||
pub login: String,
|
||||
pub server: String,
|
||||
}
|
||||
|
||||
impl CurrentAccount {
|
||||
/// Parse common.ini to extract active account info
|
||||
/// Handles UTF-16LE encoding which MT5 uses on Windows/Wine
|
||||
pub fn from_common_ini(terminal_dir: &Path) -> Option<Self> {
|
||||
let common_ini = terminal_dir.join("config").join("common.ini");
|
||||
if !common_ini.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Try reading as UTF-16LE first (MT5 default encoding)
|
||||
let bytes = fs::read(&common_ini).ok()?;
|
||||
let content = if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
|
||||
// UTF-16LE BOM detected
|
||||
let start = if bytes.len() >= 2 { 2 } else { 0 };
|
||||
let u16_slice: Vec<u16> = bytes[start..]
|
||||
.chunks(2)
|
||||
.map(|chunk| {
|
||||
if chunk.len() == 2 {
|
||||
u16::from_le_bytes([chunk[0], chunk[1]])
|
||||
} else {
|
||||
chunk[0] as u16
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
String::from_utf16(&u16_slice).ok()?
|
||||
} else {
|
||||
// Try UTF-8 fallback
|
||||
String::from_utf8(bytes).ok()?
|
||||
};
|
||||
|
||||
let mut login = None;
|
||||
let mut server = None;
|
||||
|
||||
for line in content.lines() {
|
||||
// Remove null bytes and control characters but keep printable ASCII and valid Unicode
|
||||
let cleaned: String = line.chars()
|
||||
.filter(|c| *c != '\0' && !c.is_control())
|
||||
.collect();
|
||||
|
||||
let trimmed = cleaned.trim();
|
||||
if trimmed.starts_with("Login=") {
|
||||
let val = trimmed.strip_prefix("Login=").map(|s| s.trim().to_string());
|
||||
if let Some(v) = val {
|
||||
if !v.is_empty() {
|
||||
login = Some(v);
|
||||
}
|
||||
}
|
||||
} else if trimmed.starts_with("Server=") {
|
||||
let val = trimmed.strip_prefix("Server=").map(|s| s.trim().to_string());
|
||||
if let Some(v) = val {
|
||||
if !v.is_empty() {
|
||||
server = Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match (login, server) {
|
||||
(Some(l), Some(s)) => Some(Self { login: l, server: s }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub wine_executable: Option<String>,
|
||||
@@ -26,6 +97,7 @@ pub struct Config {
|
||||
pub reports_dir: Option<String>,
|
||||
pub backtest_login: Option<String>,
|
||||
pub backtest_server: Option<String>,
|
||||
pub backtest_password: Option<String>,
|
||||
pub project_dir: Option<String>,
|
||||
}
|
||||
|
||||
@@ -52,6 +124,7 @@ impl Default for Config {
|
||||
reports_dir: None,
|
||||
backtest_login: None,
|
||||
backtest_server: None,
|
||||
backtest_password: None,
|
||||
project_dir: None,
|
||||
}
|
||||
}
|
||||
@@ -73,13 +146,52 @@ impl Config {
|
||||
Ok(discovered)
|
||||
}
|
||||
|
||||
/// The canonical writable config location: $MT5_MCP_HOME/config/mt5-quant.yaml
|
||||
/// or ~/.config/mt5-quant/config/mt5-quant.yaml.
|
||||
/// The canonical writable config location, checked in order:
|
||||
/// 1. $MT5_MCP_HOME/config/mt5-quant.yaml (user override)
|
||||
/// 2. Config next to binary (for portable/development installs)
|
||||
/// 3. ~/.config/mt5-quant/config/mt5-quant.yaml (standard location)
|
||||
/// 4. Development fallback (project directory)
|
||||
pub fn writable_config_path() -> PathBuf {
|
||||
// 1. Check env override first
|
||||
if let Ok(home) = std::env::var("MT5_MCP_HOME") {
|
||||
return Path::new(&home).join("config").join("mt5-quant.yaml");
|
||||
}
|
||||
Self::installation_dir().join("config").join("mt5-quant.yaml")
|
||||
|
||||
// 2. Check if config exists next to the binary
|
||||
if let Some(binary_dir) = Self::binary_dir() {
|
||||
let local_config = binary_dir.join("config").join("mt5-quant.yaml");
|
||||
if local_config.exists() {
|
||||
return local_config;
|
||||
}
|
||||
|
||||
// If binary is in a non-standard path (not system bin), use it
|
||||
let binary_str = binary_dir.to_string_lossy();
|
||||
if !binary_str.starts_with("/usr/local/bin")
|
||||
&& !binary_str.starts_with("/usr/bin")
|
||||
&& !binary_str.starts_with("/bin")
|
||||
{
|
||||
return binary_dir.join("config").join("mt5-quant.yaml");
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check standard location
|
||||
let standard_config = Self::standard_config_dir().join("config").join("mt5-quant.yaml");
|
||||
if standard_config.exists() {
|
||||
return standard_config;
|
||||
}
|
||||
|
||||
// 4. Development fallback - use project directory
|
||||
let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let dev_config = manifest_dir.parent()
|
||||
.unwrap_or(manifest_dir)
|
||||
.join("config")
|
||||
.join("mt5-quant.yaml");
|
||||
if dev_config.exists() {
|
||||
return dev_config;
|
||||
}
|
||||
|
||||
// 5. Fall back to standard location (will be created if not exists)
|
||||
Self::standard_config_dir().join("config").join("mt5-quant.yaml")
|
||||
}
|
||||
|
||||
// ── Auto-discovery ────────────────────────────────────────────────────────
|
||||
@@ -135,19 +247,21 @@ impl Config {
|
||||
|
||||
fn find_wine(home: &Path) -> Option<String> {
|
||||
let candidates: &[PathBuf] = &[
|
||||
// macOS: bundled with the official MT5 app
|
||||
// macOS: bundled with the official MT5 app (binary is just named 'wine' on recent builds)
|
||||
PathBuf::from("/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine"),
|
||||
PathBuf::from("/Applications/MetaTrader 5.app/Contents/SharedSupport/wine/bin/wine64"),
|
||||
// macOS: CrossOver
|
||||
// macOS: CrossOver (new versions may use 'wine', older ones 'wine64')
|
||||
home.join("Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine"),
|
||||
home.join("Applications/CrossOver.app/Contents/SharedSupport/CrossOver/wine/bin/wine64"),
|
||||
// macOS: Homebrew (Apple Silicon)
|
||||
PathBuf::from("/opt/homebrew/bin/wine64"),
|
||||
// macOS: Homebrew Apple Silicon (prefer 'wine', fall back to 'wine64')
|
||||
PathBuf::from("/opt/homebrew/bin/wine"),
|
||||
// macOS: Homebrew (Intel)
|
||||
PathBuf::from("/usr/local/bin/wine64"),
|
||||
PathBuf::from("/opt/homebrew/bin/wine64"),
|
||||
// macOS: Homebrew Intel
|
||||
PathBuf::from("/usr/local/bin/wine"),
|
||||
PathBuf::from("/usr/local/bin/wine64"),
|
||||
// Linux
|
||||
PathBuf::from("/usr/bin/wine64"),
|
||||
PathBuf::from("/usr/bin/wine"),
|
||||
PathBuf::from("/usr/bin/wine64"),
|
||||
];
|
||||
candidates.iter()
|
||||
.find(|p| p.exists())
|
||||
@@ -210,6 +324,8 @@ impl Config {
|
||||
wine_executable: {wine}\n\
|
||||
terminal_dir: {term}\n\
|
||||
experts_dir: {exp}\n\
|
||||
indicators_dir: {ind}\n\
|
||||
scripts_dir: {scr}\n\
|
||||
tester_profiles_dir: {prof}\n\
|
||||
tester_cache_dir: {cache}\n\
|
||||
display_mode: {disp}\n\
|
||||
@@ -227,6 +343,8 @@ impl Config {
|
||||
wine = s(&self.wine_executable),
|
||||
term = s(&self.terminal_dir),
|
||||
exp = s(&self.experts_dir),
|
||||
ind = s(&self.indicators_dir),
|
||||
scr = s(&self.scripts_dir),
|
||||
prof = s(&self.tester_profiles_dir),
|
||||
cache = s(&self.tester_cache_dir),
|
||||
disp = s(&self.display_mode),
|
||||
@@ -290,6 +408,7 @@ impl Config {
|
||||
reports_dir: map.get("reports_dir").cloned(),
|
||||
backtest_login: map.get("backtest_login").cloned(),
|
||||
backtest_server: map.get("backtest_server").cloned(),
|
||||
backtest_password: map.get("backtest_password").cloned(),
|
||||
project_dir: map.get("project_dir").cloned(),
|
||||
})
|
||||
}
|
||||
@@ -316,16 +435,46 @@ impl Config {
|
||||
"reports_dir" => self.reports_dir.clone().unwrap_or_else(|| "reports".to_string()),
|
||||
"backtest_login" => self.backtest_login.clone().unwrap_or_default(),
|
||||
"backtest_server" => self.backtest_server.clone().unwrap_or_default(),
|
||||
"backtest_password" => self.backtest_password.clone().unwrap_or_default(),
|
||||
"project_dir" => self.project_dir.clone().unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Root of the MCP installation: $MT5_MCP_HOME or ~/.config/mt5-quant
|
||||
/// Root of the MCP installation.
|
||||
/// Priority: $MT5_MCP_HOME > binary parent dir > ~/.config/mt5-quant
|
||||
pub fn installation_dir() -> PathBuf {
|
||||
// 1. Check env override first
|
||||
if let Ok(home) = std::env::var("MT5_MCP_HOME") {
|
||||
return Path::new(&home).to_path_buf();
|
||||
}
|
||||
|
||||
// 2. Check if binary is in a non-standard location (development/portable)
|
||||
// with an existing config file
|
||||
if let Some(binary_dir) = Self::binary_dir() {
|
||||
let binary_str = binary_dir.to_string_lossy();
|
||||
let is_system_path = binary_str.starts_with("/usr/local/bin")
|
||||
|| binary_str.starts_with("/usr/bin")
|
||||
|| binary_str.starts_with("/bin");
|
||||
|
||||
if !is_system_path && binary_dir.join("config").join("mt5-quant.yaml").exists() {
|
||||
return binary_dir;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fall back to standard location
|
||||
Self::standard_config_dir()
|
||||
}
|
||||
|
||||
/// Get the directory where the current binary is located
|
||||
fn binary_dir() -> Option<PathBuf> {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|p| p.to_path_buf()))
|
||||
}
|
||||
|
||||
/// Standard config directory in user's home
|
||||
fn standard_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_else(|| Path::new(".").to_path_buf())
|
||||
.join(".config")
|
||||
@@ -361,24 +510,59 @@ impl Config {
|
||||
self.terminal_dir.as_ref().map(|d| Path::new(d).to_path_buf())
|
||||
}
|
||||
|
||||
/// Scan Bases/*/history/ for symbol directories that contain at least one .hcc file.
|
||||
/// Returns deduplicated, sorted list of symbol names available for backtesting.
|
||||
pub fn discover_symbols(&self) -> Vec<String> {
|
||||
/// Scan the tester's own history store for symbols with downloaded data.
|
||||
///
|
||||
/// MT5 maintains two separate history trees:
|
||||
/// • `Bases/{server}/history/` — live-trading tick/bar data (NOT usable by tester)
|
||||
/// • `Tester/bases/{server}/history/` — data the Strategy Tester actually reads
|
||||
///
|
||||
/// Scanning `Bases/` (the old approach) returned symbols that exist for live trading
|
||||
/// but may have no tester data, causing the tester to fail with "symbol does not exist".
|
||||
/// This function scans `Tester/bases/` instead, which is the authoritative source.
|
||||
///
|
||||
/// Falls back to `Bases/` only when `Tester/bases/` is absent (first-run / no backtests yet).
|
||||
///
|
||||
/// If `server_filter` is provided only that server's directory is scanned.
|
||||
pub fn discover_symbols(&self, server_filter: Option<&str>) -> Vec<String> {
|
||||
let mt5_dir = match self.mt5_dir() {
|
||||
Some(d) => d,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let bases_dir = mt5_dir.join("Bases");
|
||||
if !bases_dir.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Prefer the tester's own data store; fall back to live-trading Bases/ when absent.
|
||||
let tester_bases = mt5_dir.join("Tester").join("bases");
|
||||
let bases_dir = if tester_bases.is_dir() {
|
||||
tester_bases
|
||||
} else {
|
||||
let fallback = mt5_dir.join("Bases");
|
||||
if !fallback.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
tracing::warn!(
|
||||
"Tester/bases/ not found — falling back to Bases/ for symbol discovery. \
|
||||
Run at least one backtest to populate tester data."
|
||||
);
|
||||
fallback
|
||||
};
|
||||
|
||||
let mut symbols = std::collections::HashSet::new();
|
||||
|
||||
// Bases/{server}/history/{symbol}/{year}.hcc
|
||||
// {bases_dir}/{server}/history/{symbol}/ — directory presence = data available
|
||||
// (the tester uses .hst/.hcc files; existence of the directory is sufficient)
|
||||
if let Ok(servers) = fs::read_dir(&bases_dir) {
|
||||
for server in servers.filter_map(|e| e.ok()) {
|
||||
let server_name_os = server.file_name();
|
||||
let server_name = server_name_os.to_str().unwrap_or("");
|
||||
|
||||
if server_name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(filter) = server_filter {
|
||||
if server_name != filter {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let history_dir = server.path().join("history");
|
||||
if !history_dir.is_dir() {
|
||||
continue;
|
||||
@@ -389,23 +573,8 @@ impl Config {
|
||||
if !sym_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
// Only include if at least one .hcc file exists (has downloaded data)
|
||||
let has_data = fs::read_dir(&sym_path)
|
||||
.ok()
|
||||
.map(|entries| {
|
||||
entries.filter_map(|e| e.ok()).any(|e| {
|
||||
e.path().extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.map(|x| x == "hcc")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_data {
|
||||
if let Some(name) = sym_path.file_name().and_then(|n| n.to_str()) {
|
||||
symbols.insert(name.to_string());
|
||||
}
|
||||
if let Some(name) = sym_path.file_name().and_then(|n| n.to_str()) {
|
||||
symbols.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,4 +585,97 @@ impl Config {
|
||||
sorted.sort();
|
||||
sorted
|
||||
}
|
||||
|
||||
/// Find the closest available tester symbol to the one requested.
|
||||
///
|
||||
/// Matching priority (first hit wins):
|
||||
/// 1. Exact match → `XAUUSD.cent` == `XAUUSD.cent`
|
||||
/// 2. Case-insensitive exact match → `xauusd.cent` → `XAUUSD.cent`
|
||||
/// 3. Strip/add common cent suffixes → `XAUUSDc` ↔ `XAUUSD.cent`
|
||||
/// 4. Prefix match on the base ticker → `XAUUSD` matches `XAUUSD.cent`
|
||||
pub fn resolve_symbol<'a>(requested: &str, available: &'a [String]) -> Option<&'a str> {
|
||||
if available.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 1. Exact
|
||||
if let Some(s) = available.iter().find(|s| s.as_str() == requested) {
|
||||
return Some(s.as_str());
|
||||
}
|
||||
|
||||
// 2. Case-insensitive exact
|
||||
let req_lower = requested.to_lowercase();
|
||||
if let Some(s) = available.iter().find(|s| s.to_lowercase() == req_lower) {
|
||||
return Some(s.as_str());
|
||||
}
|
||||
|
||||
// 3. Cent-suffix normalisation: build a normalised "base" for both sides
|
||||
// Strip known cent suffixes: `.cent`, `c` (trailing, uppercase only), `.c`
|
||||
fn base_ticker(sym: &str) -> &str {
|
||||
let s = sym.trim_end_matches(".cent")
|
||||
.trim_end_matches(".c");
|
||||
// Strip trailing lowercase 'c' only when the rest is all-uppercase
|
||||
// (so "XAUUSDc" → "XAUUSD", but "Misc" stays "Misc")
|
||||
if s.ends_with('c') && s[..s.len()-1].chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) {
|
||||
&s[..s.len()-1]
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
let req_base = base_ticker(requested).to_lowercase();
|
||||
if let Some(s) = available.iter().find(|s| base_ticker(s).to_lowercase() == req_base) {
|
||||
return Some(s.as_str());
|
||||
}
|
||||
|
||||
// 4. Prefix match: available symbol starts with the requested string (or vice-versa)
|
||||
if let Some(s) = available.iter().find(|s| {
|
||||
let sl = s.to_lowercase();
|
||||
sl.starts_with(&req_lower) || req_lower.starts_with(sl.as_str())
|
||||
}) {
|
||||
return Some(s.as_str());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Get the currently active MT5 account from common.ini
|
||||
pub fn current_account(&self) -> Option<CurrentAccount> {
|
||||
self.mt5_dir().and_then(|d| CurrentAccount::from_common_ini(&d))
|
||||
}
|
||||
|
||||
/// Discover symbols for the currently active account/server only
|
||||
pub fn discover_symbols_for_active_account(&self) -> Vec<String> {
|
||||
match self.current_account() {
|
||||
Some(account) => self.discover_symbols(Some(&account.server)),
|
||||
None => self.discover_symbols(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all available servers that have symbol data
|
||||
pub fn available_servers(&self) -> Vec<String> {
|
||||
let mt5_dir = match self.mt5_dir() {
|
||||
Some(d) => d,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let bases_dir = mt5_dir.join("Bases");
|
||||
if !bases_dir.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut servers = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&bases_dir) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
servers.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
servers.sort();
|
||||
servers
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,6 +3,6 @@ pub mod deals;
|
||||
pub mod metrics;
|
||||
pub mod report;
|
||||
|
||||
pub use config::Config;
|
||||
pub use config::{Config, CurrentAccount};
|
||||
pub use deals::Deal;
|
||||
pub use metrics::Metrics;
|
||||
|
||||
+51
-4
@@ -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 {
|
||||
@@ -11,8 +14,6 @@ pub struct Report {
|
||||
pub from_date: String,
|
||||
pub to_date: String,
|
||||
pub metrics_file: PathBuf,
|
||||
pub deals_csv: PathBuf,
|
||||
pub deals_json: PathBuf,
|
||||
pub analysis_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -31,14 +32,14 @@ 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)]
|
||||
pub struct FilePaths {
|
||||
pub metrics: String,
|
||||
pub analysis: String,
|
||||
pub deals_csv: String,
|
||||
pub deals_json: String,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -48,6 +49,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 +64,47 @@ 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,
|
||||
/// Set by background monitor: "running"|"completed"|"completed_no_html"|"failed"|"timeout"
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
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,
|
||||
status: Some("running".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ use std::path::Path;
|
||||
use std::process::Command;
|
||||
use tokio::process::Command as AsyncCommand;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::models::Config;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Mt5Manager {
|
||||
@@ -35,7 +35,7 @@ impl Mt5Manager {
|
||||
let mut all_ok = true;
|
||||
|
||||
// Check config file
|
||||
let config_path = Config::get_config_path();
|
||||
let config_path = Config::writable_config_path();
|
||||
if config_path.exists() {
|
||||
checks.insert("config_file".to_string(), json!({
|
||||
"ok": true,
|
||||
|
||||
+257
-75
@@ -6,6 +6,27 @@ use std::process::{Command, Stdio};
|
||||
|
||||
use crate::models::Config;
|
||||
|
||||
/// Read a file that may be UTF-16LE (with BOM) or UTF-8, returning a UTF-8 String.
|
||||
/// MT5 .set and .ini files are typically UTF-16LE with BOM (0xFF 0xFE).
|
||||
fn read_file_as_utf8(path: &Path) -> Result<String> {
|
||||
let bytes = fs::read(path)?;
|
||||
|
||||
// Check for UTF-16LE BOM (0xFF 0xFE)
|
||||
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
|
||||
// UTF-16LE with BOM - skip the 2-byte BOM and decode
|
||||
let utf16_data: Vec<u16> = bytes[2..]
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
|
||||
.collect();
|
||||
String::from_utf16(&utf16_data)
|
||||
.map_err(|e| anyhow!("Failed to decode UTF-16LE: {}", e))
|
||||
} else {
|
||||
// Try UTF-8
|
||||
String::from_utf8(bytes)
|
||||
.map_err(|e| anyhow!("Failed to decode as UTF-8: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OptimizationParams {
|
||||
pub expert: String,
|
||||
pub set_file: String,
|
||||
@@ -13,7 +34,6 @@ pub struct OptimizationParams {
|
||||
pub from_date: String,
|
||||
pub to_date: String,
|
||||
pub deposit: u32,
|
||||
pub model: u8,
|
||||
pub leverage: u32,
|
||||
pub currency: String,
|
||||
}
|
||||
@@ -27,7 +47,6 @@ impl Default for OptimizationParams {
|
||||
from_date: String::new(),
|
||||
to_date: String::new(),
|
||||
deposit: 10000,
|
||||
model: 0,
|
||||
leverage: 500,
|
||||
currency: "USD".to_string(),
|
||||
}
|
||||
@@ -78,7 +97,8 @@ impl OptimizationRunner {
|
||||
let log_file = PathBuf::from(format!("/tmp/mt5opt_{}.log", timestamp));
|
||||
|
||||
// Count combinations
|
||||
let combinations = self.count_combinations(¶ms.set_file)?;
|
||||
let combinations = self.count_combinations(¶ms.set_file)
|
||||
.map_err(|e| anyhow!("count_combinations failed: {}", e))?;
|
||||
|
||||
// Get paths
|
||||
let mt5_dir = self.config.terminal_dir.as_ref()
|
||||
@@ -89,50 +109,167 @@ impl OptimizationRunner {
|
||||
// Write .set file as UTF-16LE with BOM directly to MT5 tester directory
|
||||
let wine_prefix_dir = self.get_wine_prefix_dir(mt5_dir)?;
|
||||
let tester_dir = wine_prefix_dir.join("drive_c/Program Files/MetaTrader 5/MQL5/Profiles/Tester");
|
||||
fs::create_dir_all(&tester_dir)?;
|
||||
fs::create_dir_all(&tester_dir).map_err(|e| anyhow!("create_dir_all({}) failed: {}", tester_dir.display(), e))?;
|
||||
let dst_set_file = tester_dir.join(format!("{}.set", params.expert));
|
||||
self.write_utf16le_set(¶ms.set_file, &dst_set_file)?;
|
||||
self.write_utf16le_set(¶ms.set_file, &dst_set_file)
|
||||
.map_err(|e| anyhow!("write_utf16le_set({}) failed: {}", dst_set_file.display(), e))?;
|
||||
|
||||
// Reset OptMode in terminal.ini
|
||||
self.reset_optmode(mt5_dir)?;
|
||||
// Patch terminal.ini [Tester] section with optimization params (primary mechanism)
|
||||
let terminal_ini = if Path::new(mt5_dir).join("config").exists() {
|
||||
Path::new(mt5_dir).join("config").join("terminal.ini")
|
||||
} else {
|
||||
Path::new(mt5_dir).join("terminal.ini")
|
||||
};
|
||||
let mt5_ini_text = if terminal_ini.exists() {
|
||||
read_file_as_utf8(&terminal_ini).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let expert_path = if let Some(experts_dir) = &self.config.experts_dir {
|
||||
let nested = Path::new(experts_dir).join(¶ms.expert).join(format!("{}.mq5", params.expert));
|
||||
if nested.exists() {
|
||||
format!("Experts\\{}\\{}.ex5", params.expert, params.expert)
|
||||
} else {
|
||||
format!("Experts\\{}.ex5", params.expert)
|
||||
}
|
||||
} else {
|
||||
format!("Experts\\{}.ex5", params.expert)
|
||||
};
|
||||
let tester_section = format!(
|
||||
"[Tester]\n\
|
||||
Expert={}\n\
|
||||
ExpertParameters={}.set\n\
|
||||
Symbol={}\n\
|
||||
Period=M1\n\
|
||||
Model=4\n\
|
||||
FromDate={}\n\
|
||||
ToDate={}\n\
|
||||
ForwardMode=0\n\
|
||||
Deposit={}\n\
|
||||
Currency={}\n\
|
||||
ProfitInPips=0\n\
|
||||
Leverage={}\n\
|
||||
Execution=10\n\
|
||||
Optimization=2\n\
|
||||
Agents=10\n\
|
||||
Visual=0\n\
|
||||
Report=reports\\opt_report.htm\n\
|
||||
ReplaceReport=1\n\
|
||||
ShutdownTerminal=1",
|
||||
expert_path, params.expert, params.symbol,
|
||||
params.from_date, params.to_date, params.deposit, params.currency, params.leverage,
|
||||
);
|
||||
let agents_section = "\
|
||||
[Agents]\n\
|
||||
Agent0000=11111111-1111-1111-1111-111111111111\n\
|
||||
AgentStatus0000=3\n\
|
||||
AgentState0000=0\n\
|
||||
Enabled0000=1\n\
|
||||
IP0000=127.0.0.1\n\
|
||||
Port0000=3000\n\
|
||||
Agent0001=22222222-2222-2222-2222-222222222222\n\
|
||||
AgentStatus0001=3\n\
|
||||
AgentState0001=0\n\
|
||||
Enabled0001=1\n\
|
||||
IP0001=127.0.0.1\n\
|
||||
Port0001=3001\n\
|
||||
Agent0002=33333333-3333-3333-3333-333333333333\n\
|
||||
AgentStatus0002=3\n\
|
||||
AgentState0002=0\n\
|
||||
Enabled0002=1\n\
|
||||
IP0002=127.0.0.1\n\
|
||||
Port0002=3002\n\
|
||||
Agent0003=44444444-4444-4444-4444-444444444444\n\
|
||||
AgentStatus0003=3\n\
|
||||
AgentState0003=0\n\
|
||||
Enabled0003=1\n\
|
||||
IP0003=127.0.0.1\n\
|
||||
Port0003=3003";
|
||||
let updated_ini = Self::patch_ini_section(&mt5_ini_text, "Tester", &tester_section);
|
||||
// Strip any stale [Agents] sections from previous runs, then append fresh one
|
||||
let cleaned = Self::strip_ini_section(&updated_ini, "Agents");
|
||||
let final_ini = format!("{}\n{}", cleaned.trim_end(), agents_section);
|
||||
let mut utf16_out: Vec<u8> = vec![0xFF, 0xFE];
|
||||
utf16_out.extend(final_ini.encode_utf16().flat_map(|c| c.to_le_bytes()));
|
||||
fs::write(&terminal_ini, utf16_out)?;
|
||||
|
||||
// Get Wine prefix directory
|
||||
let wine_prefix_dir = self.get_wine_prefix_dir(mt5_dir)?;
|
||||
// Write /config: INI to trigger tester/optimizer mode
|
||||
// For /config: format, Expert path is relative to MQL5/Experts/ (no Experts\ prefix)
|
||||
let opt_config_win = r"C:\mt5opt_config.ini";
|
||||
let opt_config_host = wine_prefix_dir.join("drive_c").join("mt5opt_config.ini");
|
||||
let mut opt_ini = String::new();
|
||||
if let Some(login) = &self.config.backtest_login {
|
||||
if let Some(server) = &self.config.backtest_server {
|
||||
opt_ini.push_str("[Common]\n");
|
||||
opt_ini.push_str(&format!("Login={}\n", login));
|
||||
opt_ini.push_str(&format!("Server={}\n", server));
|
||||
if let Some(password) = &self.config.backtest_password {
|
||||
opt_ini.push_str(&format!("Password={}\n", password));
|
||||
}
|
||||
opt_ini.push_str("\n");
|
||||
}
|
||||
}
|
||||
opt_ini.push_str("[Tester]\n");
|
||||
opt_ini.push_str(&format!("Expert={}.ex5\n", params.expert));
|
||||
opt_ini.push_str(&format!("ExpertParameters={}.set\n", params.expert));
|
||||
opt_ini.push_str(&format!("Symbol={}\n", params.symbol));
|
||||
opt_ini.push_str("Period=M1\n");
|
||||
opt_ini.push_str("Optimization=2\n");
|
||||
opt_ini.push_str(&format!("FromDate={}\n", params.from_date));
|
||||
opt_ini.push_str(&format!("ToDate={}\n", params.to_date));
|
||||
opt_ini.push_str("ForwardMode=0\n");
|
||||
opt_ini.push_str(&format!("Deposit={}\n", params.deposit));
|
||||
opt_ini.push_str(&format!("Currency={}\n", params.currency));
|
||||
opt_ini.push_str("ProfitInPips=0\n");
|
||||
opt_ini.push_str(&format!("Leverage={}\n", params.leverage));
|
||||
opt_ini.push_str("Execution=10\n");
|
||||
opt_ini.push_str("Visual=0\n");
|
||||
opt_ini.push_str("Report=reports\\opt_report.htm\n");
|
||||
opt_ini.push_str("ReplaceReport=1\n");
|
||||
opt_ini.push_str("ShutdownTerminal=1\n");
|
||||
fs::write(&opt_config_host, opt_ini.as_bytes())?;
|
||||
|
||||
// Build optimization INI
|
||||
let ini_path = wine_prefix_dir.join("drive_c/mt5mcp_backtest.ini");
|
||||
let ini_content = format!(r#"[Tester]
|
||||
Expert={}
|
||||
Symbol={}
|
||||
Period=M5
|
||||
Deposit={}
|
||||
Currency={}
|
||||
Leverage={}
|
||||
Model={}
|
||||
FromDate={}
|
||||
ToDate={}
|
||||
Report=C:\mt5mcp_opt_report
|
||||
Optimization=2
|
||||
ExpertParameters={}.set
|
||||
ShutdownTerminal=1
|
||||
"#, params.expert, params.symbol, params.deposit, params.currency,
|
||||
params.leverage, params.model, params.from_date, params.to_date, params.expert);
|
||||
fs::write(&ini_path, ini_content)?;
|
||||
// Build launch script (macOS-compatible with /config: to trigger tester mode)
|
||||
let wine_bin = Path::new(wine_exe);
|
||||
let wine_root = wine_bin
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.ok_or_else(|| anyhow!("Cannot derive Wine root from wine_exe"))?;
|
||||
let ext_libs = wine_root.join("lib").join("external");
|
||||
let wine_libs = wine_root.join("lib");
|
||||
let dyld = format!("{}:{}:/usr/lib:/usr/local/lib",
|
||||
ext_libs.display(), wine_libs.display());
|
||||
let terminal_host = wine_prefix_dir.join("drive_c")
|
||||
.join("Program Files").join("MetaTrader 5").join("terminal64.exe");
|
||||
|
||||
// Build batch file
|
||||
let batch_path = wine_prefix_dir.join("drive_c/mt5mcp_run.bat");
|
||||
let batch_content = format!(r#"@echo off
|
||||
"C:\Program Files\MetaTrader 5\terminal64.exe" /config:C:\mt5mcp_backtest.ini
|
||||
"#);
|
||||
fs::write(&batch_path, batch_content)?;
|
||||
let script = format!(
|
||||
"#!/bin/sh\n\
|
||||
export DYLD_FALLBACK_LIBRARY_PATH='{dyld}'\n\
|
||||
export WINEPREFIX='{prefix}'\n\
|
||||
export WINEDEBUG='-all'\n\
|
||||
nohup '{wine}' '{terminal}' '/config:{config}' >/dev/null 2>&1 &\n",
|
||||
dyld = dyld,
|
||||
prefix = wine_prefix_dir.display(),
|
||||
wine = wine_exe,
|
||||
terminal = terminal_host.display(),
|
||||
config = opt_config_win,
|
||||
);
|
||||
|
||||
// Launch detached process
|
||||
let cmd = format!("cmd.exe /c 'C:\\mt5mcp_run.bat'");
|
||||
let child = Command::new(wine_exe)
|
||||
.arg(&cmd)
|
||||
let script_path = std::env::temp_dir().join("mt5opt_launch.sh");
|
||||
fs::write(&script_path, &script)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
|
||||
let child = Command::new("/bin/sh")
|
||||
.arg(&script_path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
.spawn()
|
||||
.map_err(|e| anyhow!("spawn /bin/sh {} failed: {}", script_path.display(), e))?;
|
||||
|
||||
let pid = child.id();
|
||||
|
||||
@@ -150,7 +287,7 @@ ShutdownTerminal=1
|
||||
}
|
||||
|
||||
fn count_combinations(&self, set_file: &str) -> Result<u64> {
|
||||
let content = fs::read_to_string(set_file)?;
|
||||
let content = read_file_as_utf8(Path::new(set_file))?;
|
||||
let mut total: u64 = 1;
|
||||
|
||||
for line in content.lines() {
|
||||
@@ -179,13 +316,23 @@ ShutdownTerminal=1
|
||||
}
|
||||
|
||||
fn write_utf16le_set(&self, src: &str, dst: &Path) -> Result<()> {
|
||||
let content = fs::read_to_string(src)?;
|
||||
let content = read_file_as_utf8(Path::new(src))?;
|
||||
|
||||
// Create parent directory if needed
|
||||
if let Some(parent) = dst.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// Remove existing file if read-only from previous run
|
||||
if dst.exists() {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(dst, fs::Permissions::from_mode(0o644));
|
||||
}
|
||||
let _ = fs::remove_file(dst);
|
||||
}
|
||||
|
||||
// Write UTF-16LE with BOM
|
||||
let mut utf16_content: Vec<u16> = vec![0xFEFF]; // BOM
|
||||
utf16_content.extend(content.encode_utf16());
|
||||
@@ -195,50 +342,18 @@ ShutdownTerminal=1
|
||||
.collect();
|
||||
|
||||
fs::write(dst, bytes)?;
|
||||
|
||||
// Make read-only
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(dst, fs::Permissions::from_mode(0o444))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset_optmode(&self, mt5_dir: &str) -> Result<()> {
|
||||
let terminal_ini = Path::new(mt5_dir).join("terminal.ini");
|
||||
|
||||
if !terminal_ini.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&terminal_ini)?;
|
||||
let updated = content
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.starts_with("OptMode=") {
|
||||
"OptMode=0".to_string()
|
||||
} else if line.starts_with("LastOptimization=") {
|
||||
String::new()
|
||||
} else {
|
||||
line.to_string()
|
||||
}
|
||||
})
|
||||
.filter(|l| !l.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
fs::write(&terminal_ini, updated)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_wine_prefix_dir(&self, mt5_dir: &str) -> Result<PathBuf> {
|
||||
let path = Path::new(mt5_dir);
|
||||
// Go up two levels: .../drive_c/Program Files/MetaTrader 5 -> .../drive_c
|
||||
// Go up three levels: .../drive_c/Program Files/MetaTrader 5 -> .../net.metaquotes.wine.metatrader5
|
||||
// (same as backtest pipeline)
|
||||
let prefix_dir = path
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent())
|
||||
.ok_or_else(|| anyhow!("Cannot determine Wine prefix from terminal_dir"))?;
|
||||
Ok(prefix_dir.to_path_buf())
|
||||
}
|
||||
@@ -276,6 +391,73 @@ ShutdownTerminal=1
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace a [section] in an INI string — removes old content and inserts new.
|
||||
fn patch_ini_section(text: &str, section: &str, new_content: &str) -> String {
|
||||
let section_header = format!("[{}]", section);
|
||||
let mut result = String::new();
|
||||
let mut in_section = false;
|
||||
let mut section_found = false;
|
||||
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == section_header {
|
||||
in_section = true;
|
||||
section_found = true;
|
||||
continue;
|
||||
}
|
||||
if in_section {
|
||||
if trimmed.starts_with('[') {
|
||||
in_section = false;
|
||||
result.push_str(new_content);
|
||||
if !new_content.ends_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
}
|
||||
|
||||
if !section_found {
|
||||
if !result.is_empty() && !result.ends_with('\n') {
|
||||
result.push('\n');
|
||||
}
|
||||
result.push_str(new_content);
|
||||
result.push('\n');
|
||||
} else if in_section {
|
||||
result.push_str(new_content);
|
||||
result.push('\n');
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Remove all lines belonging to a [section] from the INI text.
|
||||
fn strip_ini_section(text: &str, section: &str) -> String {
|
||||
let header = format!("[{}]", section);
|
||||
let mut result = String::new();
|
||||
let mut skipping = false;
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == header {
|
||||
skipping = true;
|
||||
continue;
|
||||
}
|
||||
if skipping && trimmed.starts_with('[') {
|
||||
skipping = false;
|
||||
}
|
||||
if !skipping {
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn get_job_status(&self, job_id: &str) -> Result<serde_json::Value> {
|
||||
let jobs_dir = Path::new(".mt5mcp_jobs");
|
||||
let meta_path = jobs_dir.join(format!("{}.json", job_id));
|
||||
|
||||
+903
-85
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,8 @@ use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::models::Deal;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct ReportEntry {
|
||||
pub id: String,
|
||||
@@ -67,6 +69,25 @@ impl ReportDb {
|
||||
}
|
||||
let conn = self.connect()?;
|
||||
conn.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS deals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_id TEXT NOT NULL REFERENCES reports(id) ON DELETE CASCADE,
|
||||
time TEXT NOT NULL,
|
||||
deal TEXT NOT NULL,
|
||||
symbol TEXT NOT NULL,
|
||||
deal_type TEXT NOT NULL,
|
||||
entry TEXT NOT NULL,
|
||||
volume REAL NOT NULL,
|
||||
price REAL NOT NULL,
|
||||
order_id TEXT NOT NULL,
|
||||
commission REAL NOT NULL,
|
||||
swap REAL NOT NULL,
|
||||
profit REAL NOT NULL,
|
||||
balance REAL NOT NULL,
|
||||
comment TEXT NOT NULL,
|
||||
magic TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_deals_report_id ON deals(report_id);
|
||||
CREATE TABLE IF NOT EXISTS reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
expert TEXT NOT NULL,
|
||||
@@ -102,6 +123,69 @@ impl ReportDb {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn insert_deals(&self, report_id: &str, deals: &[Deal]) -> Result<()> {
|
||||
if deals.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"INSERT INTO deals (report_id, time, deal, symbol, deal_type, entry, volume, price, \
|
||||
order_id, commission, swap, profit, balance, comment, magic) \
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)",
|
||||
)?;
|
||||
for d in deals {
|
||||
stmt.execute(params![
|
||||
report_id,
|
||||
d.time,
|
||||
d.deal,
|
||||
d.symbol,
|
||||
d.deal_type,
|
||||
d.entry,
|
||||
d.volume,
|
||||
d.price,
|
||||
d.order,
|
||||
d.commission,
|
||||
d.swap,
|
||||
d.profit,
|
||||
d.balance,
|
||||
d.comment,
|
||||
d.magic,
|
||||
])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_deals(&self, report_id: &str) -> Result<Vec<Deal>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT time, deal, symbol, deal_type, entry, volume, price, order_id, \
|
||||
commission, swap, profit, balance, comment, magic \
|
||||
FROM deals WHERE report_id = ? ORDER BY id ASC",
|
||||
)?;
|
||||
let deals: Vec<Deal> = stmt
|
||||
.query_map([report_id], |row| {
|
||||
Ok(Deal {
|
||||
time: row.get(0)?,
|
||||
deal: row.get(1)?,
|
||||
symbol: row.get(2)?,
|
||||
deal_type: row.get(3)?,
|
||||
entry: row.get(4)?,
|
||||
volume: row.get(5)?,
|
||||
price: row.get(6)?,
|
||||
order: row.get(7)?,
|
||||
commission: row.get(8)?,
|
||||
swap: row.get(9)?,
|
||||
profit: row.get(10)?,
|
||||
balance: row.get(11)?,
|
||||
comment: row.get(12)?,
|
||||
magic: row.get(13)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(deals)
|
||||
}
|
||||
|
||||
pub fn insert(&self, entry: &ReportEntry) -> Result<()> {
|
||||
let conn = self.connect()?;
|
||||
let tags_json = serde_json::to_string(&entry.tags)?;
|
||||
@@ -374,4 +458,605 @@ impl ReportDb {
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Get a specific report by its report_dir path (exact match)
|
||||
pub fn get_by_report_dir(&self, report_dir: &str) -> Result<Option<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE report_dir = ?"
|
||||
)?;
|
||||
|
||||
let entry = stmt
|
||||
.query_map([report_dir], |row| {
|
||||
let tags_json: String = row.get(23)?;
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
id: row.get(0)?,
|
||||
expert: row.get(1)?,
|
||||
symbol: row.get(2)?,
|
||||
timeframe: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
from_date: row.get(5)?,
|
||||
to_date: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
set_file_original: row.get(8)?,
|
||||
set_snapshot_path: row.get(9)?,
|
||||
report_dir: row.get(10)?,
|
||||
charts_dir: row.get(11)?,
|
||||
net_profit: row.get(12)?,
|
||||
profit_factor: row.get(13)?,
|
||||
max_dd_pct: row.get(14)?,
|
||||
sharpe_ratio: row.get(15)?,
|
||||
total_trades: row.get(16)?,
|
||||
win_rate_pct: row.get(17)?,
|
||||
recovery_factor: row.get(18)?,
|
||||
deposit: row.get(19)?,
|
||||
currency: row.get(20)?,
|
||||
leverage: row.get(21)?,
|
||||
duration_seconds: row.get(22)?,
|
||||
tags,
|
||||
notes: row.get(24)?,
|
||||
verdict: row.get(25)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.next();
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Get a specific report by ID
|
||||
pub fn get_by_id(&self, id: &str) -> Result<Option<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
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 base_sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE 1=1";
|
||||
|
||||
let (sql, params): (String, Vec<rusqlite::types::Value>) = if tags.is_empty() {
|
||||
(
|
||||
format!("{} ORDER BY created_at DESC LIMIT ?1", base_sql),
|
||||
vec![(limit as i64).into()],
|
||||
)
|
||||
} else {
|
||||
let placeholders = tags.iter().enumerate()
|
||||
.map(|(i, _)| format!("tags LIKE ?{}", i + 1))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" OR ");
|
||||
let sql = format!(
|
||||
"{} AND ({}) ORDER BY created_at DESC LIMIT ?{}",
|
||||
base_sql,
|
||||
placeholders,
|
||||
tags.len() + 1
|
||||
);
|
||||
let mut p: Vec<rusqlite::types::Value> = tags.iter()
|
||||
.map(|t| format!("%{}%", t).into())
|
||||
.collect();
|
||||
p.push((limit as i64).into());
|
||||
(sql, p)
|
||||
};
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
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 notes LIKE ?1 ORDER BY created_at DESC LIMIT ?2"
|
||||
)?;
|
||||
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map(rusqlite::params![pattern, limit as i64], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
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);
|
||||
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 (set_file_original LIKE ?1 OR set_snapshot_path LIKE ?1) \
|
||||
ORDER BY created_at DESC LIMIT ?2"
|
||||
)?;
|
||||
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map(rusqlite::params![pattern, limit as i64], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
// Common description suffix for all analytics tools
|
||||
const REPORT_HINT: &str = "report_id (preferred), report_dir (legacy path), or omit for latest report.";
|
||||
|
||||
pub fn tool_analyze_report() -> Value {
|
||||
json!({
|
||||
"name": "analyze_report",
|
||||
@@ -7,7 +10,8 @@ pub fn tool_analyze_report() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" },
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory (use report_id instead)" },
|
||||
"analytics": {
|
||||
"type": "array",
|
||||
"description": "Optional: specific analytics to run. If omitted, runs all.",
|
||||
@@ -29,7 +33,8 @@ pub fn tool_analyze_monthly_pnl() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -42,7 +47,8 @@ pub fn tool_analyze_drawdown_events() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -55,7 +61,8 @@ pub fn tool_analyze_top_losses() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" },
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" },
|
||||
"limit": { "type": "integer", "description": "Number of losses to return (default: 10)", "default": 10 }
|
||||
}
|
||||
}
|
||||
@@ -69,7 +76,8 @@ pub fn tool_analyze_loss_sequences() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -82,7 +90,8 @@ pub fn tool_analyze_position_pairs() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -95,7 +104,8 @@ pub fn tool_analyze_direction_bias() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -108,7 +118,8 @@ pub fn tool_analyze_streaks() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -121,7 +132,162 @@ pub fn tool_analyze_concurrent_peak() -> Value {
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_list_deals() -> Value {
|
||||
json!({
|
||||
"name": "list_deals",
|
||||
"description": "List individual deals from a backtest report with optional filters",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" },
|
||||
"deal_type": { "type": "string", "enum": ["buy", "sell"], "description": "Filter by deal type" },
|
||||
"min_profit": { "type": "number", "description": "Minimum profit (use negative for losses)" },
|
||||
"max_profit": { "type": "number", "description": "Maximum profit" },
|
||||
"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": ["query"],
|
||||
"properties": {
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" },
|
||||
"query": { "type": "string", "description": "Search text in comments" },
|
||||
"limit": { "type": "integer", "default": 50 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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": ["magic"],
|
||||
"properties": {
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" },
|
||||
"magic": { "type": "string", "description": "Magic number to filter by" },
|
||||
"limit": { "type": "integer", "default": 100 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
"properties": {
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
"properties": {
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
"properties": {
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
"properties": {
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
"properties": {
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_costs() -> Value {
|
||||
json!({
|
||||
"name": "analyze_costs",
|
||||
"description": "Analyze commission and swap costs impact on profitability",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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",
|
||||
"properties": {
|
||||
"report_id": { "type": "string", "description": REPORT_HINT },
|
||||
"report_dir": { "type": "string", "description": "Legacy: path to report directory" }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,87 @@ use serde_json::{json, Value};
|
||||
pub fn tool_run_backtest() -> Value {
|
||||
json!({
|
||||
"name": "run_backtest",
|
||||
"description": "Run a complete MT5 backtest pipeline: compile → clean cache → backtest → extract → analyze",
|
||||
"description": "Full backtest pipeline: compile EA → clean cache → run backtest → extract results → analyze. Use this when you have modified the EA source code.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert"],
|
||||
"properties": {
|
||||
"expert": { "type": "string", "description": "EA name without path or extension" },
|
||||
"symbol": { "type": "string", "description": "Trading symbol (default: from config or first available)" },
|
||||
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD (default: past complete month)" },
|
||||
"to_date": { "type": "string", "description": "End date YYYY.MM.DD (default: past complete month)" },
|
||||
"timeframe": { "type": "string", "enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1"], "description": "Chart timeframe (default: M5)" },
|
||||
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" },
|
||||
"model": { "type": "integer", "enum": [0, 1, 2], "description": "Tick model: 0=Every tick, 1=OHLC, 2=Open prices" },
|
||||
"set_file": { "type": "string", "description": "Path to .set parameter file for EA inputs" },
|
||||
"skip_compile": { "type": "boolean", "description": "Skip compilation (use existing .ex5)" },
|
||||
"skip_clean": { "type": "boolean", "description": "Skip cache cleaning" },
|
||||
"skip_analyze": { "type": "boolean", "description": "Skip analysis phase" },
|
||||
"deep": { "type": "boolean", "description": "Run deep analysis with extra metrics" },
|
||||
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest completes" },
|
||||
"kill_existing": { "type": "boolean", "description": "Kill any running MT5 instance first" },
|
||||
"timeout": { "type": "integer", "description": "Max wait time in seconds (default: 900)" },
|
||||
"gui": { "type": "boolean", "description": "Enable MT5 visualization window" },
|
||||
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10, set to 0 for default 10s)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_run_backtest_quick() -> Value {
|
||||
json!({
|
||||
"name": "run_backtest_quick",
|
||||
"description": "Quick backtest using pre-compiled EA: clean cache → run backtest → extract → analyze. Skips compilation. Use when EA code hasn't changed.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert"],
|
||||
"properties": {
|
||||
"expert": { "type": "string", "description": "EA name without path or extension (must have .ex5 compiled)" },
|
||||
"symbol": { "type": "string", "description": "Trading symbol (default: from config or first available)" },
|
||||
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD (default: past complete month)" },
|
||||
"to_date": { "type": "string", "description": "End date YYYY.MM.DD (default: past complete month)" },
|
||||
"timeframe": { "type": "string", "enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1"], "description": "Chart timeframe (default: M5)" },
|
||||
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" },
|
||||
"model": { "type": "integer", "enum": [0, 1, 2], "description": "Tick model" },
|
||||
"set_file": { "type": "string", "description": "Path to .set parameter file for EA inputs" },
|
||||
"deep": { "type": "boolean", "description": "Run deep analysis" },
|
||||
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest" },
|
||||
"timeout": { "type": "integer", "description": "Max wait time in seconds (default: 900)" },
|
||||
"gui": { "type": "boolean", "description": "Enable MT5 visualization" },
|
||||
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_run_backtest_only() -> Value {
|
||||
json!({
|
||||
"name": "run_backtest_only",
|
||||
"description": "Backtest only: just run backtest and extract results. No compile, no analysis. Fastest option when you just need raw trade data.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert"],
|
||||
"properties": {
|
||||
"expert": { "type": "string", "description": "EA name without path or extension (must have .ex5 compiled)" },
|
||||
"symbol": { "type": "string", "description": "Trading symbol (default: from config)" },
|
||||
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD" },
|
||||
"to_date": { "type": "string", "description": "End date YYYY.MM.DD" },
|
||||
"timeframe": { "type": "string", "enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1"], "description": "Chart timeframe (default: M5)" },
|
||||
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" },
|
||||
"model": { "type": "integer", "enum": [0, 1, 2], "description": "Tick model" },
|
||||
"set_file": { "type": "string", "description": "Path to .set parameter file" },
|
||||
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest" },
|
||||
"timeout": { "type": "integer", "description": "Max wait time (default: 900)" },
|
||||
"gui": { "type": "boolean", "description": "Enable MT5 visualization" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_launch_backtest() -> Value {
|
||||
json!({
|
||||
"name": "launch_backtest",
|
||||
"description": "Launch MT5 backtest in fire-and-forget mode: compile → clean cache → launch MT5 backtest, then return immediately. Use get_backtest_status to poll for completion.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert"],
|
||||
@@ -18,12 +98,11 @@ 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)" },
|
||||
"shutdown": { "type": "boolean", "description": "Shut down MT5 after test (default: true — required for HTML report to be written)" },
|
||||
"gui": { "type": "boolean", "description": "Enable visualization during backtest" },
|
||||
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" },
|
||||
"inactivity_kill_secs": { "type": "integer", "description": "Kill MT5 if tester log hasn't grown for this many seconds (0 = disabled). Use to abort EAs that stop trading mid-test." }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -42,6 +121,22 @@ pub fn tool_get_backtest_status() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_tester_log() -> Value {
|
||||
json!({
|
||||
"name": "get_tester_log",
|
||||
"description": "Read the active MT5 tester agent journal log. Returns parsed deals, final balance, test progress, and raw log tail. Works during a backtest (if the log is being written) or after completion. Use this to inspect what trades occurred, check for EA activity, or debug issues when the HTML report wasn't produced.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tail_lines": {
|
||||
"type": "integer",
|
||||
"description": "Number of log tail lines to return (default: 100)"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_cache_status() -> Value {
|
||||
json!({
|
||||
"name": "cache_status",
|
||||
|
||||
@@ -53,3 +53,101 @@ pub fn tool_list_scripts() -> Value {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_experts() -> Value {
|
||||
json!({
|
||||
"name": "search_experts",
|
||||
"description": "Search for Expert Advisors by name pattern across MT5 Experts directory and subdirectories",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Search pattern (case-insensitive substring match)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_indicators() -> Value {
|
||||
json!({
|
||||
"name": "search_indicators",
|
||||
"description": "Search for indicators by name pattern across MT5 directories and subdirectories",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Search pattern (case-insensitive substring match)"
|
||||
},
|
||||
"include_builtin": {
|
||||
"type": "boolean",
|
||||
"description": "Include built-in MT5 indicators in search",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_scripts() -> Value {
|
||||
json!({
|
||||
"name": "search_scripts",
|
||||
"description": "Search for scripts by name pattern across MT5 Scripts directory and subdirectories",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Search pattern (case-insensitive substring match)"
|
||||
}
|
||||
},
|
||||
"required": ["pattern"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_copy_indicator_to_project() -> Value {
|
||||
json!({
|
||||
"name": "copy_indicator_to_project",
|
||||
"description": "Copy an indicator file from MT5 Indicators directory to the project directory",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_path": {
|
||||
"type": "string",
|
||||
"description": "Full source path to the indicator file (.mq5 or .ex5)"
|
||||
},
|
||||
"target_name": {
|
||||
"type": "string",
|
||||
"description": "Optional: Rename the file (without extension). If not provided, uses original name"
|
||||
}
|
||||
},
|
||||
"required": ["source_path"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_copy_script_to_project() -> Value {
|
||||
json!({
|
||||
"name": "copy_script_to_project",
|
||||
"description": "Copy a script file from MT5 Scripts directory to the project directory",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_path": {
|
||||
"type": "string",
|
||||
"description": "Full source path to the script file (.mq5 or .ex5)"
|
||||
},
|
||||
"target_name": {
|
||||
"type": "string",
|
||||
"description": "Optional: Rename the file (without extension). If not provided, uses original name"
|
||||
}
|
||||
},
|
||||
"required": ["source_path"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,12 +8,17 @@ pub mod optimization;
|
||||
pub mod reports;
|
||||
pub mod setfiles;
|
||||
pub mod system;
|
||||
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_get_tester_log(), // Live journal reading mid-backtest or after
|
||||
backtest::tool_cache_status(),
|
||||
backtest::tool_clean_cache(),
|
||||
// Optimization
|
||||
@@ -21,7 +26,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(),
|
||||
@@ -31,6 +36,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
|
||||
@@ -38,10 +54,37 @@ pub fn get_tools_list() -> Value {
|
||||
experts::tool_list_experts(),
|
||||
experts::tool_list_indicators(),
|
||||
experts::tool_list_scripts(),
|
||||
experts::tool_search_experts(),
|
||||
experts::tool_search_indicators(),
|
||||
experts::tool_search_scripts(),
|
||||
experts::tool_copy_indicator_to_project(),
|
||||
experts::tool_copy_script_to_project(),
|
||||
// System
|
||||
system::tool_verify_setup(),
|
||||
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(),
|
||||
utility::tool_compare_backtests(),
|
||||
utility::tool_init_project(),
|
||||
utility::tool_validate_ea_syntax(),
|
||||
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(),
|
||||
@@ -51,7 +94,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(),
|
||||
@@ -62,6 +105,15 @@ 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(),
|
||||
reports::tool_export_deals_csv(),
|
||||
];
|
||||
|
||||
serde_json::json!(tools)
|
||||
|
||||
@@ -3,17 +3,17 @@ use serde_json::{json, Value};
|
||||
pub fn tool_run_optimization() -> Value {
|
||||
json!({
|
||||
"name": "run_optimization",
|
||||
"description": "Launch MT5 genetic parameter optimization",
|
||||
"description": "Launch MT5 genetic parameter optimization in fire-and-forget mode. Returns immediately with job_id. Use get_optimization_status to poll for completion. Optimization typically runs for 2-6 hours.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert", "set_file", "from_date", "to_date"],
|
||||
"properties": {
|
||||
"expert": { "type": "string" },
|
||||
"set_file": { "type": "string" },
|
||||
"symbol": { "type": "string" },
|
||||
"from_date": { "type": "string" },
|
||||
"to_date": { "type": "string" },
|
||||
"deposit": { "type": "integer" }
|
||||
"expert": { "type": "string", "description": "EA name without path or extension" },
|
||||
"set_file": { "type": "string", "description": "Path to .set file with parameter ranges for optimization" },
|
||||
"symbol": { "type": "string", "description": "Trading symbol (default: XAUUSD)" },
|
||||
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD" },
|
||||
"to_date": { "type": "string", "description": "End date YYYY.MM.DD" },
|
||||
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -22,12 +22,12 @@ pub fn tool_run_optimization() -> Value {
|
||||
pub fn tool_get_optimization_status() -> Value {
|
||||
json!({
|
||||
"name": "get_optimization_status",
|
||||
"description": "Check progress of a running optimization job",
|
||||
"description": "Check progress of a running optimization job. Poll periodically until status shows 'completed'.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["job_id"],
|
||||
"properties": {
|
||||
"job_id": { "type": "string" }
|
||||
"job_id": { "type": "string", "description": "Job ID returned by run_optimization" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -36,14 +36,14 @@ pub fn tool_get_optimization_status() -> Value {
|
||||
pub fn tool_get_optimization_results() -> Value {
|
||||
json!({
|
||||
"name": "get_optimization_results",
|
||||
"description": "Parse completed MT5 optimization results",
|
||||
"description": "Parse completed MT5 optimization results and find best parameter combinations",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": { "type": "string" },
|
||||
"report_file": { "type": "string" },
|
||||
"dd_threshold": { "type": "number" },
|
||||
"top_n": { "type": "integer" }
|
||||
"job_id": { "type": "string", "description": "Job ID to parse results for" },
|
||||
"report_file": { "type": "string", "description": "Direct path to optimization report XML file" },
|
||||
"dd_threshold": { "type": "number", "description": "Max drawdown percentage filter" },
|
||||
"top_n": { "type": "integer", "description": "Number of top passes to return (default: 30)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -52,7 +52,7 @@ pub fn tool_get_optimization_results() -> Value {
|
||||
pub fn tool_list_jobs() -> Value {
|
||||
json!({
|
||||
"name": "list_jobs",
|
||||
"description": "List running and completed optimization jobs",
|
||||
"description": "List all running and completed optimization jobs with their status",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
|
||||
@@ -148,3 +148,147 @@ 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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_export_deals_csv() -> Value {
|
||||
json!({
|
||||
"name": "export_deals_csv",
|
||||
"description": "Export deals for a report to a CSV file on demand. Deals are stored in the database — use this to get a CSV file for external tools.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_id": { "type": "string", "description": "Report ID to export (default: latest report)" },
|
||||
"output_path": { "type": "string", "description": "File path for the CSV output (default: <report_dir>/deals.csv)" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,3 +35,35 @@ pub fn tool_list_symbols() -> Value {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_active_account() -> Value {
|
||||
json!({
|
||||
"name": "get_active_account",
|
||||
"description": "Get the currently active MT5 account session info (login, server, available symbols). Use this before backtesting to ensure symbol compatibility.",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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": {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub fn tool_check_update() -> Value {
|
||||
json!({
|
||||
"name": "check_update",
|
||||
"description": "Check if a newer version of mt5-quant is available on GitHub. A background check runs automatically on the first tool call of each session; this tool returns that cached result instantly or fetches it on demand.",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_update() -> Value {
|
||||
json!({
|
||||
"name": "update",
|
||||
"description": "Download and install the latest mt5-quant binary from GitHub Releases, then replace the current executable in place. Restart the MCP connection after updating to load the new version.",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub fn tool_check_symbol_data_status() -> Value {
|
||||
json!({
|
||||
"name": "check_symbol_data_status",
|
||||
"description": "Validate if a symbol has sufficient historical tick data for a specified date range before running backtest",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"symbol": {
|
||||
"type": "string",
|
||||
"description": "Symbol to check (e.g., 'XAUUSDc')"
|
||||
},
|
||||
"from_date": {
|
||||
"type": "string",
|
||||
"description": "Start date in YYYY.MM.DD format"
|
||||
},
|
||||
"to_date": {
|
||||
"type": "string",
|
||||
"description": "End date in YYYY.MM.DD format"
|
||||
}
|
||||
},
|
||||
"required": ["symbol", "from_date", "to_date"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_get_backtest_history() -> Value {
|
||||
json!({
|
||||
"name": "get_backtest_history",
|
||||
"description": "List all backtests previously run for a specific EA and/or symbol with summary metrics",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expert": {
|
||||
"type": "string",
|
||||
"description": "EA name to filter by"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string",
|
||||
"description": "Symbol to filter by"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results (default: 10)",
|
||||
"default": 10
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_compare_backtests() -> Value {
|
||||
json!({
|
||||
"name": "compare_backtests",
|
||||
"description": "Compare two or more backtest results side-by-side with key metrics analysis",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dirs": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "List of report directory paths to compare"
|
||||
}
|
||||
},
|
||||
"required": ["report_dirs"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_init_project() -> Value {
|
||||
json!({
|
||||
"name": "init_project",
|
||||
"description": "Create a new MQL5 project with standard directory structure and template files",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Project name (will be used for EA filename)"
|
||||
},
|
||||
"template": {
|
||||
"type": "string",
|
||||
"enum": ["scalper", "swing", "grid", "basic"],
|
||||
"description": "Project template type",
|
||||
"default": "basic"
|
||||
}
|
||||
},
|
||||
"required": ["name"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_validate_ea_syntax() -> Value {
|
||||
json!({
|
||||
"name": "validate_ea_syntax",
|
||||
"description": "Perform pre-compile syntax check on MQL5 source file without running full compilation",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Path to .mq5 source file"
|
||||
}
|
||||
},
|
||||
"required": ["path"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_check_mt5_status() -> Value {
|
||||
json!({
|
||||
"name": "check_mt5_status",
|
||||
"description": "Check if MT5 terminal is installed, properly configured, and ready to run",
|
||||
"inputSchema": {
|
||||
"type": "object"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_create_set_template() -> Value {
|
||||
json!({
|
||||
"name": "create_set_template",
|
||||
"description": "Generate a .set parameter file template based on EA's input variables",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ea": {
|
||||
"type": "string",
|
||||
"description": "EA name or path to .mq5/.ex5 file"
|
||||
},
|
||||
"output_path": {
|
||||
"type": "string",
|
||||
"description": "Optional output path for .set file"
|
||||
}
|
||||
},
|
||||
"required": ["ea"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_export_report() -> Value {
|
||||
json!({
|
||||
"name": "export_report",
|
||||
"description": "Export backtest report to various formats (CSV, JSON, Markdown)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": {
|
||||
"type": "string",
|
||||
"description": "Path to backtest report directory"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["csv", "json", "md"],
|
||||
"description": "Export format",
|
||||
"default": "csv"
|
||||
},
|
||||
"output_path": {
|
||||
"type": "string",
|
||||
"description": "Optional custom output file path"
|
||||
}
|
||||
},
|
||||
"required": ["report_dir"]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 metrics.json, DB deal count, 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
+231
-230
@@ -6,304 +6,305 @@ use std::path::Path;
|
||||
use crate::analytics::DealAnalyzer;
|
||||
use crate::models::deals::Deal;
|
||||
use crate::models::metrics::Metrics;
|
||||
use crate::models::Config;
|
||||
use crate::storage::ReportDb;
|
||||
|
||||
/// Helper to load deals and metrics from report directory
|
||||
fn load_report_data(report_dir: &str) -> Result<(Vec<Deal>, Metrics)> {
|
||||
let deals_csv = Path::new(report_dir).join("deals.csv");
|
||||
let metrics_json = Path::new(report_dir).join("metrics.json");
|
||||
// ── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
if !deals_csv.exists() {
|
||||
return Err(anyhow::anyhow!("deals.csv not found in {}", report_dir));
|
||||
}
|
||||
fn required_str<'a>(args: &'a Value, key: &str) -> Result<&'a str> {
|
||||
args.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("{} is required", key))
|
||||
}
|
||||
|
||||
let deals = read_deals_from_csv(&deals_csv)?;
|
||||
|
||||
let metrics = if metrics_json.exists() {
|
||||
let content = fs::read_to_string(&metrics_json)?;
|
||||
serde_json::from_str(&content)?
|
||||
fn ok_response(data: Value) -> Value {
|
||||
json!({
|
||||
"content": [{ "type": "text", "text": data.to_string() }],
|
||||
"isError": false
|
||||
})
|
||||
}
|
||||
|
||||
fn err_response(msg: impl std::fmt::Display) -> Value {
|
||||
json!({
|
||||
"content": [{ "type": "text", "text": msg.to_string() }],
|
||||
"isError": true
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a report from args (report_id > report_dir > latest).
|
||||
/// Returns (deals, metrics, report_dir).
|
||||
fn resolve_report(args: &Value) -> Result<(Vec<Deal>, Metrics, String)> {
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
db.init()?;
|
||||
|
||||
let entry = if let Some(id) = args.get("report_id").and_then(|v| v.as_str()) {
|
||||
db.get_by_id(id)?
|
||||
.ok_or_else(|| anyhow::anyhow!("Report '{}' not found in DB", id))?
|
||||
} else if let Some(dir) = args.get("report_dir").and_then(|v| v.as_str()) {
|
||||
db.get_by_report_dir(dir)?
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"No DB entry for report_dir '{}'. This report may predate DB storage.", dir
|
||||
))?
|
||||
} else {
|
||||
db.get_latest()?
|
||||
.ok_or_else(|| anyhow::anyhow!("No reports in DB. Run a backtest first."))?
|
||||
};
|
||||
|
||||
let deals = db.get_deals(&entry.id)?;
|
||||
|
||||
let metrics_path = Path::new(&entry.report_dir).join("metrics.json");
|
||||
let metrics = if metrics_path.exists() {
|
||||
serde_json::from_str(&fs::read_to_string(&metrics_path)?)?
|
||||
} else {
|
||||
Metrics::default()
|
||||
};
|
||||
|
||||
Ok((deals, metrics))
|
||||
Ok((deals, metrics, entry.report_dir))
|
||||
}
|
||||
|
||||
fn prepare_analysis(args: &Value) -> Result<(Vec<Deal>, Metrics, DealAnalyzer, String)> {
|
||||
let (deals, metrics, report_dir) = resolve_report(args)?;
|
||||
Ok((deals, metrics, DealAnalyzer::new(), report_dir))
|
||||
}
|
||||
|
||||
// ── Composite analytics ───────────────────────────────────────────────────────
|
||||
|
||||
pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
let (deals, metrics, analyzer, report_dir) = prepare_analysis(args)?;
|
||||
|
||||
let (deals, metrics) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
|
||||
// Check if specific analytics requested
|
||||
let requested: Option<HashSet<String>> = args.get("analytics")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect());
|
||||
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect());
|
||||
|
||||
let top_losses_limit = args.get("top_losses_limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
|
||||
|
||||
let all = requested.is_none();
|
||||
let req = |name: &str| all || requested.as_ref().map(|s| s.contains(name)).unwrap_or(false);
|
||||
|
||||
// Build selective result
|
||||
let mut result = json!({});
|
||||
|
||||
if req("monthly_pnl") || all {
|
||||
result["monthly"] = json!(analyzer.monthly_pnl(&deals));
|
||||
}
|
||||
if req("drawdown_events") || all {
|
||||
result["dd_events"] = json!(analyzer.reconstruct_dd_events(&deals, &metrics));
|
||||
}
|
||||
if req("top_losses") || all {
|
||||
result["top_losses"] = json!(analyzer.top_losses(&deals, top_losses_limit));
|
||||
}
|
||||
if req("loss_sequences") || all {
|
||||
result["loss_sequences"] = json!(analyzer.loss_sequences(&deals));
|
||||
}
|
||||
if req("position_pairs") || all {
|
||||
result["position_pairs"] = json!(analyzer.position_pairs(&deals));
|
||||
}
|
||||
if req("direction_bias") || all {
|
||||
result["direction_bias"] = json!(analyzer.direction_bias(&deals));
|
||||
}
|
||||
if req("streak_analysis") || all {
|
||||
result["streak_analysis"] = json!(analyzer.streak_analysis(&deals));
|
||||
}
|
||||
if req("concurrent_peak") || all {
|
||||
result["concurrent_peak"] = json!(analyzer.concurrent_peak(&deals));
|
||||
}
|
||||
if req("monthly_pnl") { result["monthly"] = json!(analyzer.monthly_pnl(&deals)); }
|
||||
if req("drawdown_events") { result["dd_events"] = json!(analyzer.reconstruct_dd_events(&deals, &metrics)); }
|
||||
if req("top_losses") { result["top_losses"] = json!(analyzer.top_losses(&deals, top_losses_limit)); }
|
||||
if req("loss_sequences") { result["loss_sequences"] = json!(analyzer.loss_sequences(&deals)); }
|
||||
if req("position_pairs") { result["position_pairs"] = json!(analyzer.position_pairs(&deals)); }
|
||||
if req("direction_bias") { result["direction_bias"] = json!(analyzer.direction_bias(&deals)); }
|
||||
if req("streak_analysis") { result["streak_analysis"] = json!(analyzer.streak_analysis(&deals)); }
|
||||
if req("concurrent_peak") { result["concurrent_peak"] = json!(analyzer.concurrent_peak(&deals)); }
|
||||
|
||||
let analysis_path = Path::new(report_dir).join("analysis.json");
|
||||
let analysis_path = Path::new(&report_dir).join("analysis.json");
|
||||
fs::write(&analysis_path, serde_json::to_string_pretty(&result)?)?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"analysis_file": analysis_path.to_string_lossy(),
|
||||
"analytics_run": requested.map(|s| s.iter().cloned().collect::<Vec<_>>()).unwrap_or_else(|| vec!["all".to_string()]),
|
||||
"summary": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
fn read_deals_from_csv(path: &Path) -> Result<Vec<Deal>> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut deals = Vec::new();
|
||||
|
||||
let mut lines = content.lines();
|
||||
let _header = lines.next();
|
||||
|
||||
for line in lines {
|
||||
let parts: Vec<&str> = line.split(',').collect();
|
||||
if parts.len() >= 12 {
|
||||
deals.push(Deal {
|
||||
time: parts[0].to_string(),
|
||||
deal: parts[1].to_string(),
|
||||
symbol: parts[2].to_string(),
|
||||
deal_type: parts[3].to_string(),
|
||||
entry: parts[4].to_string(),
|
||||
volume: parts[5].parse().unwrap_or(0.0),
|
||||
price: parts[6].parse().unwrap_or(0.0),
|
||||
order: parts[7].to_string(),
|
||||
commission: parts[8].parse().unwrap_or(0.0),
|
||||
swap: parts[9].parse().unwrap_or(0.0),
|
||||
profit: parts[10].parse().unwrap_or(0.0),
|
||||
balance: parts[11].parse().unwrap_or(0.0),
|
||||
comment: parts.get(12).unwrap_or(&"").to_string(),
|
||||
magic: parts.get(13).map(|s| s.to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deals)
|
||||
Ok(ok_response(json!({
|
||||
"success": true,
|
||||
"analysis_file": analysis_path.to_string_lossy(),
|
||||
"analytics_run": requested.map(|s| s.iter().cloned().collect::<Vec<_>>()).unwrap_or_else(|| vec!["all".to_string()]),
|
||||
"summary": result,
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn handle_compare_baseline(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
let (_, _, _, report_dir) = prepare_analysis(args)?;
|
||||
|
||||
let baseline_path = Path::new("config/baseline.json");
|
||||
let metrics_path = Path::new(report_dir).join("metrics.json");
|
||||
let metrics_path = Path::new(&report_dir).join("metrics.json");
|
||||
|
||||
if !baseline_path.exists() {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": "No baseline.json found in config/" }],
|
||||
"isError": false
|
||||
}));
|
||||
return Ok(ok_response(json!("No baseline.json found in config/")));
|
||||
}
|
||||
|
||||
let baseline: Value = serde_json::from_str(&fs::read_to_string(baseline_path)?)?;
|
||||
let current: Value = serde_json::from_str(&fs::read_to_string(metrics_path)?)?;
|
||||
|
||||
let comparison = json!({
|
||||
Ok(ok_response(json!({
|
||||
"baseline": baseline,
|
||||
"current": current,
|
||||
"improvements": {
|
||||
"profit": current.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0)
|
||||
"profit": current.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0)
|
||||
- baseline.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"drawdown": current.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0)
|
||||
- baseline.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
}
|
||||
});
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": comparison.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
})))
|
||||
}
|
||||
|
||||
// === Granular Analytics Handlers ===
|
||||
// ── Granular analytics handlers ───────────────────────────────────────────────
|
||||
|
||||
pub async fn handle_analyze_monthly_pnl(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.monthly_pnl(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"monthly_pnl": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "monthly_pnl": analyzer.monthly_pnl(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_drawdown_events(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, metrics) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.reconstruct_dd_events(&deals, &metrics);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"drawdown_events": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
let (deals, metrics, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "drawdown_events": analyzer.reconstruct_dd_events(&deals, &metrics) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_top_losses(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.top_losses(&deals, limit);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"limit": limit,
|
||||
"top_losses": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "limit": limit, "top_losses": analyzer.top_losses(&deals, limit) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_loss_sequences(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.loss_sequences(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"loss_sequences": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "loss_sequences": analyzer.loss_sequences(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_position_pairs(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.position_pairs(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"position_pairs": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "position_pairs": analyzer.position_pairs(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_direction_bias(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.direction_bias(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"direction_bias": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "direction_bias": analyzer.direction_bias(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_streaks(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.streak_analysis(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"streak_analysis": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "streak_analysis": analyzer.streak_analysis(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_concurrent_peak(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
|
||||
|
||||
let (deals, _) = load_report_data(report_dir)?;
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.concurrent_peak(&deals);
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"concurrent_peak": result,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "concurrent_peak": analyzer.concurrent_peak(&deals) })))
|
||||
}
|
||||
|
||||
// Import Config for analysis module
|
||||
use crate::models::Config;
|
||||
pub async fn handle_analyze_profit_distribution(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "profit_distribution": analyzer.profit_distribution(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_time_performance(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "time_performance": analyzer.time_performance(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_hold_time_distribution(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "hold_time_analysis": analyzer.hold_time_analysis(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_layer_performance(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "layer_performance": analyzer.layer_performance(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_volume_vs_profit(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "volume_analysis": analyzer.volume_analysis(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_costs(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let (deals, _, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "cost_analysis": analyzer.cost_analysis(&deals) })))
|
||||
}
|
||||
|
||||
pub async fn handle_analyze_efficiency(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let (deals, metrics, analyzer, _) = prepare_analysis(args)?;
|
||||
Ok(ok_response(json!({ "success": true, "efficiency_analysis": analyzer.efficiency_analysis(&deals, &metrics) })))
|
||||
}
|
||||
|
||||
// ── Deal query handlers ───────────────────────────────────────────────────────
|
||||
|
||||
fn deal_to_json(d: &Deal) -> Value {
|
||||
json!({
|
||||
"time": d.time,
|
||||
"deal": d.deal,
|
||||
"symbol": d.symbol,
|
||||
"deal_type": d.deal_type,
|
||||
"volume": d.volume,
|
||||
"price": d.price,
|
||||
"profit": d.profit,
|
||||
"commission": d.commission,
|
||||
"swap": d.swap,
|
||||
"comment": d.comment,
|
||||
"magic": d.magic,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_closed_trade(d: &Deal) -> bool {
|
||||
// Accept any "out" entry. Profit may legitimately be 0.0 for journal-extracted
|
||||
// deals (where per-deal P&L is unavailable), so we don't gate on profit != 0.0.
|
||||
d.entry.to_lowercase().contains("out")
|
||||
}
|
||||
|
||||
pub async fn handle_list_deals(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let (deals, _, _, _) = prepare_analysis(args)?;
|
||||
|
||||
let deal_type = args.get("deal_type").and_then(|v| v.as_str());
|
||||
let min_profit = args.get("min_profit").and_then(|v| v.as_f64());
|
||||
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 query = required_str(args, "query")?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
let (deals, _, _, _) = prepare_analysis(args)?;
|
||||
let 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 magic = required_str(args, "magic")?;
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
|
||||
let (deals, _, _, _) = prepare_analysis(args)?;
|
||||
|
||||
let 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<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn _err_response_available() { let _ = err_response(""); }
|
||||
|
||||
+510
-34
@@ -2,52 +2,178 @@ 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
|
||||
#[derive(Debug)]
|
||||
struct BacktestPreflight {
|
||||
account: Option<crate::models::CurrentAccount>,
|
||||
available_symbols: Vec<String>,
|
||||
ea_exists: bool,
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
impl BacktestPreflight {
|
||||
fn check(config: &Config, expert: &str) -> Self {
|
||||
let account = config.current_account();
|
||||
let server = account.as_ref().map(|a| a.server.clone());
|
||||
let available_symbols = config.discover_symbols_for_active_account();
|
||||
|
||||
let ea_exists = if let Some(experts_dir) = &config.experts_dir {
|
||||
let mq5_path = std::path::Path::new(experts_dir).join(format!("{}.mq5", expert));
|
||||
let ex5_path = std::path::Path::new(experts_dir).join(format!("{}.ex5", expert));
|
||||
let subdir_mq5 = std::path::Path::new(experts_dir).join(expert).join(format!("{}.mq5", expert));
|
||||
mq5_path.exists() || ex5_path.exists() || subdir_mq5.exists()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
Self {
|
||||
account,
|
||||
available_symbols,
|
||||
ea_exists,
|
||||
server,
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn is_ready(&self) -> bool {
|
||||
self.account.is_some() && !self.available_symbols.is_empty() && self.ea_exists
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_run_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"))?;
|
||||
|
||||
// Symbol pre-flight
|
||||
// Run pre-flight check
|
||||
let preflight = BacktestPreflight::check(config, expert);
|
||||
|
||||
// Get active account context for error messages
|
||||
let active_server = preflight.server.as_deref().unwrap_or("unknown");
|
||||
let active_login = preflight.account.as_ref().map(|a| a.login.as_str()).unwrap_or("unknown");
|
||||
|
||||
// Check account session first
|
||||
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.",
|
||||
"pre_check": "account_missing"
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Symbol pre-flight with account context
|
||||
let requested_symbol = args.get("symbol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let available = config.discover_symbols();
|
||||
// No tester data at all → hard fail with helpful context
|
||||
let no_symbols_error = || json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": format!("No symbols available for backtesting on server '{}'.", active_server),
|
||||
"account": { "login": active_login, "server": active_server },
|
||||
"hint": "Open MT5 → View → Strategy Tester → download history for at least one symbol.",
|
||||
"pre_check": "no_symbols"
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
});
|
||||
|
||||
let symbol = if requested_symbol.is_empty() {
|
||||
let default = config.backtest_symbol.clone()
|
||||
.unwrap_or_else(|| "XAUUSD".to_string());
|
||||
if available.contains(&default) {
|
||||
default
|
||||
} else if let Some(first) = available.first() {
|
||||
tracing::warn!("Default symbol {} not found; using {}", default, first);
|
||||
first.clone()
|
||||
let symbol: String = if requested_symbol.is_empty() {
|
||||
// No symbol requested — use config default or first available tester symbol.
|
||||
let candidate = config.backtest_symbol.as_deref().unwrap_or("");
|
||||
if candidate.is_empty() {
|
||||
preflight.available_symbols.first()
|
||||
.cloned()
|
||||
.ok_or(()
|
||||
).unwrap_or_else(|_| return String::new())
|
||||
} else {
|
||||
default
|
||||
match Config::resolve_symbol(candidate, &preflight.available_symbols) {
|
||||
Some(resolved) => {
|
||||
if resolved != candidate {
|
||||
tracing::warn!(
|
||||
"Config symbol '{}' not in tester data for '{}'; using '{}' instead",
|
||||
candidate, active_server, resolved
|
||||
);
|
||||
}
|
||||
resolved.to_string()
|
||||
}
|
||||
None => {
|
||||
// Config default has no tester data — pick first available
|
||||
preflight.available_symbols.first()
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !available.is_empty() && !available.contains(&requested_symbol.to_string()) {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": format!("Symbol '{}' has no local history data.", requested_symbol),
|
||||
"available_symbols": available,
|
||||
"hint": "Use list_symbols to see all available symbols."
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
// Caller specified a symbol — resolve it against actual tester data.
|
||||
if preflight.available_symbols.is_empty() {
|
||||
return Ok(no_symbols_error());
|
||||
}
|
||||
match Config::resolve_symbol(requested_symbol, &preflight.available_symbols) {
|
||||
Some(resolved) if resolved == requested_symbol => {
|
||||
// Exact match — use as-is
|
||||
resolved.to_string()
|
||||
}
|
||||
Some(resolved) => {
|
||||
// Fuzzy match — proceed with the corrected symbol, surface the substitution
|
||||
tracing::warn!(
|
||||
"Symbol '{}' not in tester data for '{}'; substituting '{}'",
|
||||
requested_symbol, active_server, resolved
|
||||
);
|
||||
resolved.to_string()
|
||||
}
|
||||
None => {
|
||||
// No match at all — fail with full context so the caller can act
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": format!(
|
||||
"Symbol '{}' has no tester data on server '{}' and no close match was found.",
|
||||
requested_symbol, active_server
|
||||
),
|
||||
"account": { "login": active_login, "server": active_server },
|
||||
"requested_symbol": requested_symbol,
|
||||
"available_symbols": preflight.available_symbols,
|
||||
"hint": "The tester data for this symbol hasn't been downloaded yet. \
|
||||
Open MT5 → Strategy Tester → select the symbol and click Download.",
|
||||
"pre_check": "symbol_not_available"
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
}
|
||||
requested_symbol.to_string()
|
||||
};
|
||||
|
||||
// Date defaulting: past complete calendar month
|
||||
// Guard against the empty-string edge case (no symbols at all)
|
||||
if symbol.is_empty() {
|
||||
return Ok(no_symbols_error());
|
||||
}
|
||||
|
||||
// EA existence check with context
|
||||
if !preflight.ea_exists {
|
||||
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.",
|
||||
"pre_check": "ea_not_found"
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Date defaulting: current month
|
||||
let (from_date, to_date) = {
|
||||
let f = args.get("from_date").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let t = args.get("to_date").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if f.is_empty() || t.is_empty() {
|
||||
super::past_complete_month()
|
||||
super::current_month()
|
||||
} else {
|
||||
(f.to_string(), t.to_string())
|
||||
}
|
||||
@@ -67,10 +193,12 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value>
|
||||
skip_clean: args.get("skip_clean").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
skip_analyze: args.get("skip_analyze").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
deep_analyze: args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
shutdown: args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
shutdown: args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
kill_existing: args.get("kill_existing").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
timeout: args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900),
|
||||
gui: args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
startup_delay_secs: args.get("startup_delay_secs").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
inactivity_kill_secs: args.get("inactivity_kill_secs").and_then(|v| v.as_u64()),
|
||||
};
|
||||
|
||||
let pipeline = BacktestPipeline::new(config.clone());
|
||||
@@ -87,33 +215,381 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value>
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_run_backtest_quick(handler: &crate::tools::handlers::ToolHandler, args: &Value) -> Result<Value> {
|
||||
// Quick backtest: skip compile, clean → launch → background monitor → return job.
|
||||
// Uses the fire-and-forget launch path so the MCP response is returned immediately
|
||||
// and the result is available via get_backtest_status / get_latest_report once done.
|
||||
// (The synchronous blocking path exceeds MCP request timeouts for any backtest >2 min.)
|
||||
let mut args = args.clone();
|
||||
if let Some(obj) = args.as_object_mut() {
|
||||
obj.insert("skip_compile".to_string(), json!(true));
|
||||
}
|
||||
handle_launch_backtest(handler, &args).await
|
||||
}
|
||||
|
||||
pub async fn handle_run_backtest_only(handler: &crate::tools::handlers::ToolHandler, args: &Value) -> Result<Value> {
|
||||
// Backtest only: skip compile and analyze — launch and return job immediately.
|
||||
let mut args = args.clone();
|
||||
if let Some(obj) = args.as_object_mut() {
|
||||
obj.insert("skip_compile".to_string(), json!(true));
|
||||
obj.insert("skip_analyze".to_string(), json!(true));
|
||||
}
|
||||
handle_launch_backtest(handler, &args).await
|
||||
}
|
||||
|
||||
pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandler, args: &Value) -> Result<Value> {
|
||||
let expert = args.get("expert")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("expert is required"))?;
|
||||
|
||||
// Run pre-flight check
|
||||
let preflight = BacktestPreflight::check(&handler.config, expert);
|
||||
|
||||
// Check account session
|
||||
if preflight.account.is_none() {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": "No active MT5 account session detected.",
|
||||
"hint": "Open MT5 and login to your trading account before running backtests."
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Get symbol — resolve against actual tester data (same logic as handle_run_backtest)
|
||||
let active_server = preflight.server.as_deref().unwrap_or("unknown");
|
||||
let requested_symbol = args.get("symbol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let symbol: String = if requested_symbol.is_empty() {
|
||||
let candidate = handler.config.backtest_symbol.as_deref().unwrap_or("");
|
||||
if candidate.is_empty() {
|
||||
preflight.available_symbols.first().cloned().unwrap_or_default()
|
||||
} else {
|
||||
Config::resolve_symbol(candidate, &preflight.available_symbols)
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| preflight.available_symbols.first().cloned())
|
||||
.unwrap_or_else(|| candidate.to_string())
|
||||
}
|
||||
} else {
|
||||
match Config::resolve_symbol(requested_symbol, &preflight.available_symbols) {
|
||||
Some(resolved) => {
|
||||
if resolved != requested_symbol {
|
||||
tracing::warn!(
|
||||
"launch_backtest: symbol '{}' not in tester data for '{}'; using '{}'",
|
||||
requested_symbol, active_server, resolved
|
||||
);
|
||||
}
|
||||
resolved.to_string()
|
||||
}
|
||||
None if preflight.available_symbols.is_empty() => requested_symbol.to_string(),
|
||||
None => {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": format!(
|
||||
"Symbol '{}' has no tester data on server '{}' and no close match was found.",
|
||||
requested_symbol, active_server
|
||||
),
|
||||
"requested_symbol": requested_symbol,
|
||||
"available_symbols": preflight.available_symbols,
|
||||
"hint": "Open MT5 → Strategy Tester → select the symbol and click Download.",
|
||||
"pre_check": "symbol_not_available"
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// EA existence check
|
||||
if !preflight.ea_exists {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": format!("EA '{}' not found in Experts directory.", expert),
|
||||
"hint": "Use search_experts or list_experts to find available EAs."
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
|
||||
// Date defaulting
|
||||
let (from_date, to_date) = {
|
||||
let f = args.get("from_date").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let t = args.get("to_date").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if f.is_empty() || t.is_empty() {
|
||||
super::current_month()
|
||||
} else {
|
||||
(f.to_string(), t.to_string())
|
||||
}
|
||||
};
|
||||
|
||||
let params = BacktestParams {
|
||||
expert: expert.to_string(),
|
||||
symbol: symbol.to_string(),
|
||||
from_date: from_date.to_string(),
|
||||
to_date: to_date.to_string(),
|
||||
timeframe: args.get("timeframe").and_then(|v| v.as_str()).unwrap_or("M5").to_string(),
|
||||
deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32,
|
||||
model: args.get("model").and_then(|v| v.as_u64()).unwrap_or(0) as u8,
|
||||
leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32,
|
||||
set_file: args.get("set_file").and_then(|v| v.as_str()).map(|s| s.to_string()),
|
||||
skip_compile: args.get("skip_compile").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
skip_clean: args.get("skip_clean").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
skip_analyze: true, // Not needed for launch mode
|
||||
deep_analyze: false,
|
||||
// On Wine/macOS, ShutdownTerminal=1 does NOT reliably cause terminal64.exe to exit.
|
||||
// When inactivity_kill_secs is set, the monitor waits that many seconds after the
|
||||
// tester log goes quiet, then polls for the HTML report for 30 s, then kills MT5.
|
||||
// If no inactivity_kill_secs is given (default None → disabled), the monitor relies
|
||||
// solely on timeout (900 s) or natural MT5 exit for completion detection.
|
||||
shutdown: args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(true),
|
||||
kill_existing: false,
|
||||
timeout: args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900),
|
||||
gui: args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
startup_delay_secs: args.get("startup_delay_secs").and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
inactivity_kill_secs: args.get("inactivity_kill_secs").and_then(|v| v.as_u64()),
|
||||
};
|
||||
|
||||
let pipeline = if let Some(ref callback) = handler.notification_callback {
|
||||
BacktestPipeline::with_notification_callback(handler.config.clone(), callback.clone())
|
||||
} else {
|
||||
BacktestPipeline::new(handler.config.clone())
|
||||
};
|
||||
let job = pipeline.launch_backtest(params).await?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"message": "Backtest launched successfully. Use get_backtest_status to poll for completion.",
|
||||
"report_id": job.report_id,
|
||||
"report_dir": job.report_dir,
|
||||
"expert": job.expert,
|
||||
"symbol": job.symbol,
|
||||
"timeframe": job.timeframe,
|
||||
"launched_at": job.launched_at,
|
||||
"timeout_seconds": job.timeout_seconds,
|
||||
"poll_hint": "Call get_backtest_status with report_dir to check progress"
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("latest");
|
||||
|
||||
let progress_file = Path::new(report_dir).join("progress.log");
|
||||
let report_path = Path::new(report_dir);
|
||||
let progress_file = report_path.join("progress.log");
|
||||
let job_file = report_path.join("job.json");
|
||||
|
||||
let status = if progress_file.exists() {
|
||||
// Load job info if available
|
||||
let job: Option<BacktestJob> = if job_file.exists() {
|
||||
fs::read_to_string(&job_file)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Check progress log for stage
|
||||
let (stage, progress_lines) = if progress_file.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&progress_file) {
|
||||
let last_line = content.lines().last().unwrap_or("");
|
||||
if last_line.contains("DONE") {
|
||||
"completed"
|
||||
} else {
|
||||
"running"
|
||||
}
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let last_stage = lines.last()
|
||||
.and_then(|l| l.split_whitespace().next())
|
||||
.unwrap_or("UNKNOWN");
|
||||
(last_stage.to_string(), lines.len())
|
||||
} else {
|
||||
"unknown"
|
||||
("UNKNOWN".to_string(), 0)
|
||||
}
|
||||
} else {
|
||||
("NOT_STARTED".to_string(), 0)
|
||||
};
|
||||
|
||||
// Check if MT5 is running
|
||||
let mt5_running = is_mt5_running();
|
||||
|
||||
// Check if report file exists (still on disk — it's deleted after extraction)
|
||||
let report_found = job.as_ref()
|
||||
.map(|j| Path::new(&j.expected_report_path).exists())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Check for completed artifacts
|
||||
let metrics_exists = report_path.join("metrics.json").exists();
|
||||
|
||||
// Read the authoritative status written by the background monitor into job.json.
|
||||
// This avoids false "failed" when the HTML was extracted+deleted (report_found=false)
|
||||
// or when journal extraction ran instead of HTML extraction.
|
||||
let job_status = job.as_ref()
|
||||
.and_then(|j| j.status.as_deref())
|
||||
.unwrap_or("");
|
||||
let monitor_says_complete = matches!(job_status, "completed" | "completed_no_html");
|
||||
let monitor_says_failed = matches!(job_status, "failed" | "timeout" | "timeout_inactive");
|
||||
|
||||
let is_complete = monitor_says_complete
|
||||
|| stage == "DONE"
|
||||
|| (report_found && metrics_exists);
|
||||
|
||||
// Calculate elapsed time if job exists
|
||||
let elapsed_seconds = job.as_ref()
|
||||
.and_then(|j| {
|
||||
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 — trust monitor's job.json first
|
||||
let status_msg = if is_complete {
|
||||
"completed"
|
||||
} else if monitor_says_failed {
|
||||
if job_status == "timeout" || job_status == "timeout_inactive" { "timeout" } else { "failed" }
|
||||
} else if stage == "BACKTEST" && mt5_running {
|
||||
"running"
|
||||
} else if stage == "BACKTEST" && !mt5_running {
|
||||
"failed"
|
||||
} else if progress_lines > 0 {
|
||||
"in_progress"
|
||||
} else {
|
||||
"not_started"
|
||||
};
|
||||
|
||||
let message = if is_complete {
|
||||
if job_status == "completed_no_html" {
|
||||
"Backtest completed (extracted from tester journal — no HTML report)"
|
||||
} else {
|
||||
"Backtest completed successfully"
|
||||
}
|
||||
} else if stage == "BACKTEST" && mt5_running {
|
||||
"MT5 is running the backtest"
|
||||
} else if stage == "BACKTEST" && !mt5_running {
|
||||
"MT5 process exited — report not yet found"
|
||||
} 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,
|
||||
"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)
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the active tester agent log for live/post-test deal inspection.
|
||||
/// Returns the last N lines and a parsed deal summary.
|
||||
pub async fn handle_get_tester_log(config: &Config, args: &Value) -> Result<Value> {
|
||||
use crate::pipeline::backtest::BacktestPipeline;
|
||||
|
||||
let tail_lines = args.get("tail_lines").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
|
||||
|
||||
let log_path = match BacktestPipeline::find_active_tester_agent_log(config) {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": "No tester agent log found for today.",
|
||||
"hint": "Run a backtest first. The log appears after the tester starts."
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let lines = match BacktestPipeline::read_tester_agent_log(&log_path) {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
return Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"error": format!("Could not read log at {}", log_path.display())
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
let (deals, final_balance_pips, progress) = BacktestPipeline::parse_journal_deals(&lines);
|
||||
|
||||
// Collect tail lines
|
||||
let tail: Vec<&str> = lines.iter()
|
||||
.rev()
|
||||
.take(tail_lines)
|
||||
.rev()
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
|
||||
// Detect last sim timestamp for progress estimation
|
||||
let last_sim_time = lines.iter().rev()
|
||||
.find_map(|l| {
|
||||
let parts: Vec<&str> = l.split_whitespace().collect();
|
||||
// Format: XX 0 HH:MM:SS.mmm Core NN YYYY.MM.DD HH:MM:SS ...
|
||||
if parts.len() >= 6 {
|
||||
let date = parts[4];
|
||||
let time = parts[5];
|
||||
if date.contains('.') && time.contains(':') {
|
||||
return Some(format!("{} {}", date, time));
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"log_path": log_path.to_string_lossy(),
|
||||
"total_lines": lines.len(),
|
||||
"deals_found": deals.len(),
|
||||
"final_balance_pips": final_balance_pips,
|
||||
"progress": progress,
|
||||
"last_sim_time": last_sim_time,
|
||||
"is_complete": !progress.is_empty(),
|
||||
"tail_lines": tail,
|
||||
"deals_summary": deals.iter().map(|d| json!({
|
||||
"deal": d.deal,
|
||||
"time": d.time,
|
||||
"type": d.deal_type,
|
||||
"volume": d.volume,
|
||||
"price": d.price,
|
||||
"symbol": d.symbol,
|
||||
})).collect::<Vec<_>>()
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
|
||||
+215
-170
@@ -1,162 +1,212 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
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 {
|
||||
if let Ok(entries) = fs::read_dir(experts_dir) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
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 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 {
|
||||
if let Ok(entries) = fs::read_dir(indicators_dir) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
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 {
|
||||
if let Ok(entries) = fs::read_dir(scripts_dir) {
|
||||
for entry in entries.filter_map(|e| e.ok()) {
|
||||
let path = entry.path();
|
||||
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> {
|
||||
@@ -165,54 +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_copy_indicator_to_project(config: &Config, args: &Value) -> Result<Value> {
|
||||
copy_mql_to_project(config, args, "indicator")
|
||||
}
|
||||
|
||||
pub async fn handle_copy_script_to_project(config: &Config, args: &Value) -> Result<Value> {
|
||||
copy_mql_to_project(config, args, "script")
|
||||
}
|
||||
|
||||
+106
-7
@@ -2,8 +2,12 @@ use anyhow::Result;
|
||||
use chrono::Datelike;
|
||||
use serde_json::{json, Value};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use crate::models::Config;
|
||||
|
||||
type NotificationCallback = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
|
||||
|
||||
mod system;
|
||||
mod experts;
|
||||
mod backtest;
|
||||
@@ -11,33 +15,66 @@ mod optimization;
|
||||
mod analysis;
|
||||
mod setfiles;
|
||||
mod reports;
|
||||
mod utility;
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Cached result of the background update check.
|
||||
/// - Not yet initialized: check still in flight (or not spawned yet)
|
||||
/// - Some(version): a newer version is available
|
||||
/// - None: already on latest, or GitHub unreachable
|
||||
pub(crate) static LATEST_VERSION: OnceLock<Option<String>> = OnceLock::new();
|
||||
static BACKGROUND_CHECK_SPAWNED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToolHandler {
|
||||
pub config: Config,
|
||||
pub notification_callback: Option<NotificationCallback>,
|
||||
}
|
||||
|
||||
impl ToolHandler {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self { config }
|
||||
Self { config, notification_callback: None }
|
||||
}
|
||||
|
||||
pub fn with_notification_callback(config: Config, callback: NotificationCallback) -> Self {
|
||||
Self { config, notification_callback: Some(callback) }
|
||||
}
|
||||
|
||||
pub async fn handle(&self, name: &str, args: &Value) -> Result<Value> {
|
||||
// Spawn a one-shot background update check on the very first tool call of the session.
|
||||
if !BACKGROUND_CHECK_SPAWNED.swap(true, Ordering::Relaxed) {
|
||||
tokio::spawn(async {
|
||||
let result = system::fetch_latest_version().await;
|
||||
let _ = LATEST_VERSION.set(result);
|
||||
});
|
||||
}
|
||||
|
||||
match name {
|
||||
// System handlers
|
||||
"verify_setup" => system::handle_verify_setup(&self.config).await,
|
||||
"list_symbols" => system::handle_list_symbols(&self.config).await,
|
||||
"healthcheck" => system::handle_healthcheck(&self.config, args).await,
|
||||
"verify_setup" => system::handle_verify_setup(&self.config).await,
|
||||
"list_symbols" => system::handle_list_symbols(&self.config).await,
|
||||
"healthcheck" => system::handle_healthcheck(&self.config, args).await,
|
||||
"get_active_account" => system::handle_get_active_account(&self.config).await,
|
||||
"check_update" => system::handle_check_update(&self.config).await,
|
||||
"update" => system::handle_update(&self.config).await,
|
||||
|
||||
// Expert/Indicator/Script handlers
|
||||
"list_experts" => experts::handle_list_experts(&self.config, args).await,
|
||||
"list_indicators" => experts::handle_list_indicators(&self.config, args).await,
|
||||
"list_scripts" => experts::handle_list_scripts(&self.config, args).await,
|
||||
"compile_ea" => experts::handle_compile_ea(&self.config, args).await,
|
||||
"search_experts" => experts::handle_search_experts(&self.config, args).await,
|
||||
"search_indicators" => experts::handle_search_indicators(&self.config, args).await,
|
||||
"search_scripts" => experts::handle_search_scripts(&self.config, args).await,
|
||||
"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, args).await, // Quick: skip compile, fire-and-forget launch
|
||||
"run_backtest_only" => backtest::handle_run_backtest_only(self, args).await, // Minimal: skip compile+analyze, fire-and-forget launch
|
||||
"launch_backtest" => backtest::handle_launch_backtest(self, args).await, // Fire-and-forget mode
|
||||
"get_backtest_status" => backtest::handle_get_backtest_status(&self.config, args).await,
|
||||
"get_tester_log" => backtest::handle_get_tester_log(&self.config, args).await,
|
||||
"cache_status" => backtest::handle_cache_status(&self.config).await,
|
||||
"clean_cache" => backtest::handle_clean_cache(&self.config, args).await,
|
||||
|
||||
@@ -58,6 +95,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,
|
||||
@@ -80,7 +128,36 @@ 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,
|
||||
"export_deals_csv" => reports::handle_export_deals_csv(&self.config, args).await,
|
||||
|
||||
// Utility handlers
|
||||
"check_symbol_data_status" => utility::handle_check_symbol_data_status(&self.config, args).await,
|
||||
"get_backtest_history" => utility::handle_get_backtest_history(&self.config, args).await,
|
||||
"compare_backtests" => utility::handle_compare_backtests(&self.config, args).await,
|
||||
"init_project" => utility::handle_init_project(&self.config, args).await,
|
||||
"validate_ea_syntax" => utility::handle_validate_ea_syntax(&self.config, args).await,
|
||||
"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
|
||||
@@ -103,6 +180,7 @@ pub(crate) fn dir_size(path: &Path) -> u64 {
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn past_complete_month() -> (String, String) {
|
||||
let now = chrono::Utc::now();
|
||||
let today = chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
|
||||
@@ -116,3 +194,24 @@ pub(crate) fn past_complete_month() -> (String, String) {
|
||||
last_of_prev.format("%Y.%m.%d").to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn current_month() -> (String, String) {
|
||||
let now = chrono::Utc::now();
|
||||
let first_of_month = chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
|
||||
.unwrap_or_else(|| chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
|
||||
let last_of_month = if now.month() == 12 {
|
||||
chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1)
|
||||
.unwrap_or(first_of_month)
|
||||
.pred_opt()
|
||||
.unwrap_or(first_of_month)
|
||||
} else {
|
||||
chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1)
|
||||
.unwrap_or(first_of_month)
|
||||
.pred_opt()
|
||||
.unwrap_or(first_of_month)
|
||||
};
|
||||
(
|
||||
first_of_month.format("%Y.%m.%d").to_string(),
|
||||
last_of_month.format("%Y.%m.%d").to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ pub async fn handle_run_optimization(config: &Config, args: &Value) -> Result<Va
|
||||
from_date: from_date.to_string(),
|
||||
to_date: to_date.to_string(),
|
||||
deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32,
|
||||
model: 0,
|
||||
leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32,
|
||||
currency: args.get("currency").and_then(|v| v.as_str()).unwrap_or("USD").to_string(),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use crate::analytics::ReportExtractor;
|
||||
use crate::models::Config;
|
||||
use crate::storage::{ReportDb, ReportFilters};
|
||||
|
||||
@@ -433,3 +434,460 @@ 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
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn handle_export_deals_csv(_config: &Config, args: &Value) -> Result<Value> {
|
||||
let db = ReportDb::new(&Config::db_path());
|
||||
if let Err(e) = db.init() {
|
||||
return Ok(json!({ "content": [{ "type": "text", "text": format!("DB error: {}", e) }], "isError": true }));
|
||||
}
|
||||
|
||||
let report_id_opt = args.get("report_id").and_then(|v| v.as_str());
|
||||
let entry = match report_id_opt {
|
||||
Some(id) => db.get_by_id(id)?,
|
||||
None => db.get_latest()?,
|
||||
};
|
||||
|
||||
let entry = match entry {
|
||||
Some(e) => e,
|
||||
None => return Ok(json!({ "content": [{ "type": "text", "text": "No report found" }], "isError": true })),
|
||||
};
|
||||
|
||||
let deals = db.get_deals(&entry.id)?;
|
||||
if deals.is_empty() {
|
||||
return Ok(json!({ "content": [{ "type": "text", "text": format!("No deals stored for report {}", entry.id) }], "isError": false }));
|
||||
}
|
||||
|
||||
let output_path = match args.get("output_path").and_then(|v| v.as_str()) {
|
||||
Some(p) => std::path::PathBuf::from(p),
|
||||
None => Path::new(&entry.report_dir).join("deals.csv"),
|
||||
};
|
||||
|
||||
let extractor = ReportExtractor::new();
|
||||
extractor.write_deals_to_csv(&deals, &output_path)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write CSV: {}", e))?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"report_id": entry.id,
|
||||
"deals_count": deals.len(),
|
||||
"output_path": output_path.to_string_lossy(),
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use crate::models::Config;
|
||||
|
||||
/// Read a file that may be UTF-16LE (with BOM) or UTF-8, returning a UTF-8 String.
|
||||
fn read_file_as_utf8(path: &str) -> Result<String> {
|
||||
let bytes = fs::read(path)?;
|
||||
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
|
||||
let utf16_data: Vec<u16> = bytes[2..]
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
|
||||
.collect();
|
||||
String::from_utf16(&utf16_data)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to decode UTF-16LE: {}", e))
|
||||
} else {
|
||||
String::from_utf8(bytes)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to decode as UTF-8: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_read_set_file(args: &Value) -> Result<Value> {
|
||||
let path = args.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("path is required"))?;
|
||||
|
||||
let content = fs::read_to_string(path)?;
|
||||
let content = read_file_as_utf8(path)?;
|
||||
let mut params = serde_json::Map::new();
|
||||
|
||||
for line in content.lines() {
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
@@ -87,7 +102,7 @@ pub async fn handle_patch_set_file(args: &Value) -> Result<Value> {
|
||||
.and_then(|v| v.as_object())
|
||||
.ok_or_else(|| anyhow::anyhow!("patches object is required"))?;
|
||||
|
||||
let content = fs::read_to_string(path)?;
|
||||
let content = read_file_as_utf8(path)?;
|
||||
let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
|
||||
let mut patched_count = 0;
|
||||
|
||||
@@ -165,8 +180,8 @@ pub async fn handle_diff_set_files(args: &Value) -> Result<Value> {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("file_b is required"))?;
|
||||
|
||||
let content_a = fs::read_to_string(file_a)?;
|
||||
let content_b = fs::read_to_string(file_b)?;
|
||||
let content_a = read_file_as_utf8(file_a)?;
|
||||
let content_b = read_file_as_utf8(file_b)?;
|
||||
|
||||
let mut differences = Vec::new();
|
||||
|
||||
@@ -224,20 +239,21 @@ pub async fn handle_describe_sweep(args: &Value) -> Result<Value> {
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("path is required"))?;
|
||||
|
||||
let content = fs::read_to_string(path)?;
|
||||
let content = read_file_as_utf8(path)?;
|
||||
let mut sweep_params = serde_json::Map::new();
|
||||
|
||||
for line in content.lines() {
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
if value.contains("||Y") {
|
||||
if let Some((from_val, to_val)) = value.split_once("..") {
|
||||
let parts: Vec<&str> = value.split("||").collect();
|
||||
if parts.len() >= 5 && parts[4].trim().to_uppercase() == "Y" {
|
||||
sweep_params.insert(key.to_string(), json!({
|
||||
"from": from_val.trim(),
|
||||
"to": to_val.trim().replace("||Y", ""),
|
||||
"step": 1.0
|
||||
"from": parts[1].trim(),
|
||||
"to": parts[3].trim(),
|
||||
"step": parts[2].trim(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -267,7 +283,7 @@ pub async fn handle_list_set_files(config: &Config) -> Result<Value> {
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap_or_default();
|
||||
let content = read_file_as_utf8(&path.to_string_lossy()).unwrap_or_default();
|
||||
let param_count = content.lines().filter(|l| l.contains(':')).count();
|
||||
let sweep_count = content.lines().filter(|l| l.contains("||Y")).count();
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -60,17 +232,78 @@ pub async fn handle_verify_setup(config: &Config) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub async fn handle_list_symbols(config: &Config) -> Result<Value> {
|
||||
let symbols = config.discover_symbols();
|
||||
// Get active account info
|
||||
let current_account = config.current_account();
|
||||
let active_server = current_account.as_ref().map(|a| a.server.clone());
|
||||
|
||||
// Get all available servers for reference
|
||||
let all_servers = config.available_servers();
|
||||
|
||||
// Get symbols for active server (or all if no active account)
|
||||
let symbols = config.discover_symbols_for_active_account();
|
||||
|
||||
let hint = if symbols.is_empty() {
|
||||
if active_server.is_some() {
|
||||
"No history data found for the active account's server. Open MT5 and download tick data for the symbols you want to backtest."
|
||||
} else {
|
||||
"No history data found. Open MT5 and download tick data for the symbols you want to backtest."
|
||||
}
|
||||
} else {
|
||||
"These symbols have local tick history and can be used for backtesting."
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"count": symbols.len(),
|
||||
"symbols": symbols,
|
||||
"hint": if symbols.is_empty() {
|
||||
"No history data found. Open MT5 and download tick data for the symbols you want to backtest."
|
||||
} else {
|
||||
"These symbols have local tick history and can be used for backtesting."
|
||||
}
|
||||
"active_account": current_account.map(|a| json!({
|
||||
"login": a.login,
|
||||
"server": a.server
|
||||
})),
|
||||
"active_server": active_server,
|
||||
"available_servers": all_servers,
|
||||
"hint": hint,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get active MT5 account info with available symbols for pre-flight checks
|
||||
pub async fn handle_get_active_account(config: &Config) -> Result<Value> {
|
||||
let current_account = config.current_account();
|
||||
let active_server = current_account.as_ref().map(|a| a.server.clone());
|
||||
|
||||
// Get all available servers
|
||||
let all_servers = config.available_servers();
|
||||
|
||||
// Get symbols for active server (or all if no active account)
|
||||
let symbols = config.discover_symbols_for_active_account();
|
||||
|
||||
// Determine readiness for backtesting
|
||||
let ready_for_backtest = current_account.is_some() && !symbols.is_empty();
|
||||
|
||||
let hint = if current_account.is_none() {
|
||||
"No active MT5 account detected. Open MT5 and login to an account first."
|
||||
} else if symbols.is_empty() {
|
||||
"Active account found but no symbol history data. Download tick data in MT5 Strategy Tester."
|
||||
} else {
|
||||
"Ready for backtesting. Use these symbols with run_backtest."
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"ready_for_backtest": ready_for_backtest,
|
||||
"account": current_account.map(|a| json!({
|
||||
"login": a.login,
|
||||
"server": a.server
|
||||
})),
|
||||
"server": active_server,
|
||||
"available_servers": all_servers,
|
||||
"symbols": symbols,
|
||||
"symbol_count": symbols.len(),
|
||||
"hint": hint,
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
/// Read a file that may be UTF-16LE (with BOM) or UTF-8, returning a UTF-8 String.
|
||||
/// MT5 .set and .ini files are typically UTF-16LE with BOM (0xFF 0xFE).
|
||||
pub fn read_file_as_utf8(path: &std::path::Path) -> anyhow::Result<String> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
|
||||
// Check for UTF-16LE BOM (0xFF 0xFE)
|
||||
if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
|
||||
// UTF-16LE with BOM - skip the 2-byte BOM and decode
|
||||
let utf16_data: Vec<u16> = bytes[2..]
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
|
||||
.collect();
|
||||
String::from_utf16(&utf16_data)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to decode UTF-16LE: {}", e))
|
||||
} else {
|
||||
// Try UTF-8
|
||||
String::from_utf8(bytes)
|
||||
.map_err(|e| anyhow::anyhow!("Failed to decode as UTF-8: {}", e))
|
||||
}
|
||||
}
|
||||
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/bin/bash
|
||||
# E2E Test for all 43 MT5-Quant MCP tools
|
||||
|
||||
set -e
|
||||
|
||||
BINARY="/usr/local/bin/mt5-quant"
|
||||
FAILED=0
|
||||
PASSED=0
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo "=========================================="
|
||||
echo "MT5-Quant E2E Test - All 43 Tools"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Test helper function - sends initialize + tool call in one session
|
||||
test_tool() {
|
||||
local tool_name=$1
|
||||
local tool_request=$2
|
||||
local expected_field=$3
|
||||
|
||||
echo -n "Testing $tool_name... "
|
||||
|
||||
# Send initialize + tool call in one session (stdio transport)
|
||||
response=$(printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}\n%s\n' "$tool_request" | timeout 10 $BINARY 2>/dev/null | tail -1)
|
||||
|
||||
if echo "$response" | grep -q "$expected_field"; then
|
||||
echo -e "${GREEN}PASS${NC}"
|
||||
((PASSED++))
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}FAIL${NC}"
|
||||
echo " Request: $tool_request"
|
||||
echo " Response: $response"
|
||||
((FAILED++))
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Test 1: Initialize + tools/list
|
||||
echo "=== Core Protocol ==="
|
||||
test_tool "initialize/tools_list" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \
|
||||
'"name":"run_backtest"'
|
||||
|
||||
echo ""
|
||||
echo "=== System Tools ==="
|
||||
|
||||
# Test 2: healthcheck
|
||||
test_tool "healthcheck" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"healthcheck","arguments":{}}}' \
|
||||
'healthy'
|
||||
|
||||
# Test 3: verify_setup
|
||||
test_tool "verify_setup" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"verify_setup","arguments":{}}}' \
|
||||
'all_ok'
|
||||
|
||||
# Test 4: list_symbols
|
||||
test_tool "list_symbols" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_symbols","arguments":{}}}' \
|
||||
'symbols'
|
||||
|
||||
echo ""
|
||||
echo "=== Expert/Indicator/Script Tools ==="
|
||||
|
||||
# Test 5: list_experts
|
||||
test_tool "list_experts" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_experts","arguments":{}}}' \
|
||||
'experts'
|
||||
|
||||
# Test 6: list_indicators
|
||||
test_tool "list_indicators" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_indicators","arguments":{}}}' \
|
||||
'indicators'
|
||||
|
||||
# Test 7: list_scripts
|
||||
test_tool "list_scripts" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_scripts","arguments":{}}}' \
|
||||
'scripts'
|
||||
|
||||
echo ""
|
||||
echo "=== Report Tools ==="
|
||||
|
||||
# Test 8: list_reports
|
||||
test_tool "list_reports" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_reports","arguments":{}}}' \
|
||||
'reports'
|
||||
|
||||
# Test 9: get_latest_report
|
||||
test_tool "get_latest_report" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_latest_report","arguments":{}}}' \
|
||||
'success'
|
||||
|
||||
# Test 10: search_reports
|
||||
test_tool "search_reports" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_reports","arguments":{}}}' \
|
||||
'reports'
|
||||
|
||||
# Test 11: prune_reports
|
||||
test_tool "prune_reports" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"prune_reports","arguments":{"keep_last":10}}}' \
|
||||
'success'
|
||||
|
||||
echo ""
|
||||
echo "=== Set File Tools ==="
|
||||
|
||||
# Test 12: list_set_files
|
||||
test_tool "list_set_files" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"list_set_files","arguments":{}}}' \
|
||||
'set_files'
|
||||
|
||||
echo ""
|
||||
echo "=== Cache Tools ==="
|
||||
|
||||
# Test 13: cache_status
|
||||
test_tool "cache_status" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"cache_status","arguments":{}}}' \
|
||||
'success'
|
||||
|
||||
# Test 14: clean_cache
|
||||
test_tool "clean_cache" \
|
||||
'{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"clean_cache","arguments":{"dry_run":true}}}' \
|
||||
'success'
|
||||
|
||||
echo ""
|
||||
echo "=== Summary ==="
|
||||
echo "=========================================="
|
||||
echo -e "Passed: ${GREEN}$PASSED${NC}"
|
||||
echo -e "Failed: ${RED}$FAILED${NC}"
|
||||
echo "=========================================="
|
||||
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
echo -e "${GREEN}All tests passed!${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}Some tests failed!${NC}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""MCP test harness: run backtest then exercise all granular analysis tools."""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import threading
|
||||
|
||||
BINARY = "/Users/masdevid/.cargo/bin/mt5-quant"
|
||||
TIMEOUT = 1200 # 20 min for backtest
|
||||
|
||||
def send(proc, msg):
|
||||
line = json.dumps(msg) + "\n"
|
||||
proc.stdin.write(line.encode())
|
||||
proc.stdin.flush()
|
||||
|
||||
def recv(proc, timeout=30):
|
||||
"""Read one JSON-RPC response line."""
|
||||
proc.stdout.readline() # skip blank / notification lines
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
line = proc.stdout.readline().decode("utf-8", errors="replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
return json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
pass # skip non-JSON lines (logs etc)
|
||||
return None
|
||||
|
||||
def recv_with_id(proc, expected_id, timeout=1200):
|
||||
"""Read lines until we get a response with the expected id."""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
line = proc.stdout.readline().decode("utf-8", errors="replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
if msg.get("id") == expected_id:
|
||||
return msg
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
|
||||
def tool_call(proc, call_id, name, arguments=None):
|
||||
send(proc, {
|
||||
"jsonrpc": "2.0",
|
||||
"id": call_id,
|
||||
"method": "tools/call",
|
||||
"params": {"name": name, "arguments": arguments or {}}
|
||||
})
|
||||
|
||||
def extract_text(resp):
|
||||
if not resp:
|
||||
return "NO RESPONSE"
|
||||
result = resp.get("result", {})
|
||||
content = result.get("content", [])
|
||||
if content:
|
||||
text = content[0].get("text", "")
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
return json.dumps(parsed, indent=2)[:1500]
|
||||
except Exception:
|
||||
return text[:1500]
|
||||
if "error" in resp:
|
||||
return f"ERROR: {resp['error']}"
|
||||
return str(resp)[:500]
|
||||
|
||||
def ok(resp):
|
||||
if not resp:
|
||||
return False
|
||||
result = resp.get("result", {})
|
||||
content = result.get("content", [])
|
||||
is_error = result.get("isError", True)
|
||||
if is_error:
|
||||
return False
|
||||
if content:
|
||||
text = content[0].get("text", "")
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
return parsed.get("success", True) is not False
|
||||
except Exception:
|
||||
return True
|
||||
return not is_error
|
||||
|
||||
# ── Start server ──────────────────────────────────────────────────────────────
|
||||
|
||||
print("Starting mt5-quant MCP server...")
|
||||
proc = subprocess.Popen(
|
||||
[BINARY],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
# ── Handshake ─────────────────────────────────────────────────────────────────
|
||||
|
||||
send(proc, {
|
||||
"jsonrpc": "2.0", "id": 0,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "test-harness", "version": "1.0"}
|
||||
}
|
||||
})
|
||||
init_resp = recv_with_id(proc, 0, timeout=10)
|
||||
if not init_resp:
|
||||
print("FATAL: no initialize response")
|
||||
proc.terminate()
|
||||
sys.exit(1)
|
||||
print("Initialized OK")
|
||||
|
||||
send(proc, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
|
||||
|
||||
# ── Run backtest ──────────────────────────────────────────────────────────────
|
||||
|
||||
print("\n=== run_backtest (DPS21, XAUUSD, M5, OHLC, skip_compile) ===")
|
||||
tool_call(proc, 1, "run_backtest", {
|
||||
"expert": "DPS21",
|
||||
"symbol": "XAUUSDc",
|
||||
"timeframe": "M5",
|
||||
"model": 1, # OHLC – faster
|
||||
"skip_compile": True,
|
||||
"timeout": 900,
|
||||
"startup_delay_secs": 15,
|
||||
})
|
||||
resp = recv_with_id(proc, 1, timeout=TIMEOUT)
|
||||
print(extract_text(resp))
|
||||
backtest_ok = ok(resp)
|
||||
print(f"backtest_ok={backtest_ok}")
|
||||
|
||||
if not backtest_ok:
|
||||
print("Backtest failed – aborting analysis tests")
|
||||
proc.terminate()
|
||||
sys.exit(1)
|
||||
|
||||
# ── Full analysis ─────────────────────────────────────────────────────────────
|
||||
|
||||
print("\n=== analyze_report (latest, all analytics) ===")
|
||||
tool_call(proc, 2, "analyze_report", {})
|
||||
resp = recv_with_id(proc, 2, timeout=60)
|
||||
print(extract_text(resp))
|
||||
print(f"analyze_report ok={ok(resp)}")
|
||||
|
||||
# ── Granular analytics ────────────────────────────────────────────────────────
|
||||
|
||||
granular = [
|
||||
("analyze_monthly_pnl", {}),
|
||||
("analyze_drawdown_events", {}),
|
||||
("analyze_top_losses", {"limit": 5}),
|
||||
("analyze_loss_sequences", {}),
|
||||
("analyze_position_pairs", {}),
|
||||
("analyze_direction_bias", {}),
|
||||
("analyze_streaks", {}),
|
||||
("analyze_concurrent_peak", {}),
|
||||
("analyze_profit_distribution", {}),
|
||||
("analyze_time_performance", {}),
|
||||
("analyze_hold_time_distribution",{}),
|
||||
("analyze_layer_performance", {}),
|
||||
("analyze_volume_vs_profit", {}),
|
||||
("analyze_costs", {}),
|
||||
("analyze_efficiency", {}),
|
||||
("list_deals", {"limit": 5}),
|
||||
("search_deals_by_comment", {"query": "tp"}),
|
||||
("search_deals_by_magic", {"magic": "0"}),
|
||||
]
|
||||
|
||||
results = {}
|
||||
for i, (tool, args) in enumerate(granular, start=10):
|
||||
print(f"\n=== {tool} ===")
|
||||
tool_call(proc, i, tool, args)
|
||||
resp = recv_with_id(proc, i, timeout=30)
|
||||
status = "OK" if ok(resp) else "FAIL"
|
||||
results[tool] = status
|
||||
print(f" {status}")
|
||||
if status == "FAIL":
|
||||
print(" " + extract_text(resp)[:400])
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
|
||||
print("\n\n========== SUMMARY ==========")
|
||||
passed = [t for t, s in results.items() if s == "OK"]
|
||||
failed = [t for t, s in results.items() if s != "OK"]
|
||||
print(f"PASSED ({len(passed)}): {', '.join(passed)}")
|
||||
if failed:
|
||||
print(f"FAILED ({len(failed)}): {', '.join(failed)}")
|
||||
else:
|
||||
print("All granular analytics PASSED")
|
||||
|
||||
proc.terminate()
|
||||
Reference in New Issue
Block a user