56 Commits

Author SHA1 Message Date
Devid HW 01514623c9 release: v1.32.0
Deals-in-DB architecture: per-deal data now stored in SQLite instead of
deals.csv/deals.json files.

- storage: add `deals` table with report_id FK; insert_deals/get_deals/get_by_report_dir methods
- extract: stop writing deals.csv and deals.json; only metrics.json written to disk
- pipeline: call db.insert_deals() after extraction; HTML report still deleted after parse
- analytics: all 19 tools now load deals from DB (report_id > report_dir > latest)
- analytics: resolve_report() helper replaces load_report_data/read_deals_from_csv
- analytics: calling any analytics tool with no args uses the latest report automatically
- tools: add export_deals_csv for on-demand CSV generation from DB
- tools: add get_by_report_dir for backward-compat lookup by filesystem path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 00:54:27 +07:00
Devid HW bce0323469 docs: update README and MCP_TOOLS for deals-in-DB architecture
- README: update deal analytics count (19 dimensions, DB-backed)
- MCP_TOOLS: remove deals.csv/deals_json from backtest output schema
- MCP_TOOLS: add export_deals_csv tool documentation
- MCP_TOOLS: update analytics tools to show report_id/report_dir/latest pattern
- MCP_TOOLS: fix get_backtest_crash_info description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 00:53:51 +07:00
Devid HW ccc9e7e4d8 Move test_mcp.py to tests directory 2026-04-22 12:26:37 +07:00
Devid HW e1adcad45c Add 10 new debugging and diagnostic tools for Wine and MT5 2026-04-22 12:24:43 +07:00
Devid HW b97011d5b6 Update documentation for new debugging and diagnostic tools 2026-04-22 12:24:12 +07:00
Devid HW 41344b14b8 docs: add all missing 48 tools to MCP_TOOLS.md
Added documentation for:
- System tools (4): list_symbols, check_update, update, healthcheck
- Experts tools (3): list_experts, list_indicators, list_scripts
- Search & Discovery (5): search_experts, search_indicators, search_scripts, copy_indicator_to_project, copy_script_to_project
- Debugging/Diagnostics (9): diagnose_wine, get_mt5_logs, search_mt5_errors, check_mt5_process, kill_mt5_process, check_system_resources, validate_mt5_config, get_wine_prefix_info, get_backtest_crash_info
- Reports query (10): get_latest_report, search_reports, get_report_by_id, get_reports_summary, get_best_reports, search_reports_by_tags, search_reports_by_date_range, search_reports_by_notes, get_reports_by_set_file, get_comparable_reports
- Granular analytics (8): analyze_monthly_pnl, analyze_drawdown_events, analyze_top_losses, analyze_loss_sequences, analyze_position_pairs, analyze_direction_bias, analyze_streaks, analyze_concurrent_peak
- Deal-level analytics (10): list_deals, search_deals_by_comment, search_deals_by_magic, analyze_profit_distribution, analyze_time_performance, analyze_hold_time_distribution, analyze_layer_performance, analyze_volume_vs_profit, analyze_costs, analyze_efficiency

MCP_TOOLS.md now documents all 89 tools with full input/output schemas.
2026-04-22 08:51:26 +07:00
Devid HW 436566e019 docs: add missing tools to README.md
- Added list_symbols to Core workflow
- Added list_jobs to Optimization section
- Added promote_to_baseline to Reports section
- Added read_set_file and write_set_file to .set files section
- Added diff_set_files to .set analysis section
- Added check_update and update to Debugging section
- Added get_backtest_history to History & Comparison section

Brings documented tools from 87 to 89 (matching actual count)
2026-04-22 08:47:37 +07:00
Devid HW 74c3fdb87e docs: fix binary paths and tarball names across all documentation
Updated files:
- README.md: Fix tarball filename mt5-quant -> mcp-mt5-quant
- QUICKSTART.md: Fix tarball names and all binary paths
- WINDSURF.md: Fix tarball names, binary paths, and install script paths
- CURSOR.md: Fix tarball names and all binary path references
- VSCODE.md: Fix tarball names and all binary path references

All paths now correctly point to:
- Prebuilt: /path/to/mt5-quant/mcp-server/bin/mt5-quant
- Dev build: /path/to/mt5-quant/target/release/mt5-quant
2026-04-22 08:45:06 +07:00
Devid HW 55e0822051 docs: fix prebuilt binary path in CLAUDE.md
- Correct path: /mcp-server/bin/mt5-quant (not project root)
- Verified by downloading and extracting actual release tarball
- Updated both Full Example and Environment Variables sections
2026-04-22 08:42:26 +07:00
Devid HW 5366b45c36 docs: fix incorrect binary paths in CLAUDE.md
- Changed /target/release/mt5-quant to /mt5-quant for prebuilt binary path
- Added path notes explaining prebuilt vs dev build locations
- Fixed both Full Example and Environment Variables sections
2026-04-22 08:40:36 +07:00
Devid HW 96ba882a95 docs: simplify ARCHITECTURE.md and remove Python legacy references
- Condense Design Philosophy to Design Principles
- Remove Legacy Python analytics/ section from component map
- Remove Python CLI entry points (mt5-analyze-* commands)
- Update analyze.py reference to analyze.rs
- Simplify Claude Code Integration section
- Remove outdated hook mechanism documentation

File reduced from 459 to ~410 lines
2026-04-22 08:38:25 +07:00
Devid HW b9729b7121 docs: fix tool count inconsistencies across documentation
- README: 87 → 89 tools (tagline and docs table)
- QUICKSTART.md: 43 → 89 tools
- ARCHITECTURE.md: 43 → 89 tools

Verified actual count: 89 tools via grep of definitions/mod.rs
2026-04-22 08:35:19 +07:00
Devid HW e7d2ec5a47 docs: clarify optimization differences
Others can run optimization via terminal but lack:
- Background execution with polling
- Results parsing into structured data
- Automatic .set file generation
- Optimization history and search
2026-04-22 08:30:18 +07:00
Devid HW 7620cb3c04 docs: clarify comparison with other MT5 MCPs
- Update 'Others' column to acknowledge Windows MT5 Python package
- Clarify MQL5 compilation: others via GUI/terminal, we do headless
- Add 'Report organization' as unique MT5-Quant feature
- Emphasize focus on backtest analytics and workflow management
2026-04-22 08:27:49 +07:00
Devid HW 9a8594426a docs: add crates.io metadata and cargo install instructions
- Add crates.io package metadata (license, keywords, categories)
- Expand 'Why Rust' section with clearer benefits
- Add cargo install option alongside binary download
- Add Acknowledgements and Disclaimer sections
2026-04-22 08:14:59 +07:00
github-actions[bot] 061e36d339 ci: update server.json SHA256 for v1.31.5 [skip ci] 2026-04-22 00:36:19 +00:00
Devid HW cc4b702176 release: v1.31.5
- Version bump 1.31.4 → 1.31.5
- server.json identifier URL updated (SHA256 set by CI after build)
- CHANGELOG.md updated
2026-04-22 07:34:25 +07:00
Devid HW e58316e70e feat: add check_update and update tools with background auto-check
On the first tool call of each session, a background task fires once
and fetches the latest release tag from the GitHub API (5 s timeout).
The result is cached in a static OnceLock for the lifetime of the
process — no repeated network calls.

check_update: returns cached result instantly, or fetches on demand
  if the background task hasn't completed yet.

update: downloads the latest tarball for the current platform, extracts
  the binary, and atomically replaces the running executable via a
  temp-file rename. Requires MCP reconnect to activate new version.

Platform support: macos-aarch64, macos-x86_64, linux-x86_64.
Unsupported platforms return a build-from-source hint.

Tool count: 87 → 89. README and MCP_TOOLS.md updated.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:34:25 +07:00
github-actions[bot] a1c5d328b3 ci: update server.json SHA256 for v1.31.4 [skip ci] 2026-04-22 00:25:50 +00:00
Devid HW 6f773d6f09 release: v1.31.4
- Version bump 1.31.3 → 1.31.4
- server.json identifier URL updated (SHA256 set by CI after build)
- CHANGELOG.md updated
2026-04-22 07:24:03 +07:00
Devid HW c895eb9b33 fix: update all scripts for correctness and consistency
release.sh:
- Add --yes/-y flag (and CI=true) for non-interactive execution
- Auto-resolve server.json conflict during rebase-before-push so CI's
  SHA256 commit no longer blocks the push
- Fix major version bump (was double-assigned, now single expression)

build-release.sh:
- Remove cp of /CLAUDE.md (gitignored personal file — caused build failure)
- Bundle all IDE setup docs (QUICKSTART, CLAUDE, CURSOR, VSCODE, WINDSURF)
- Use set -euo pipefail; chmod +x binary; clean up dist/ subdir after tar

build-rust.sh:
- Replace Windsurf-specific install hint with generic cargo install --path . --force
- Use set -euo pipefail

setup.sh:
- Replace "Antigravity" with "Claude Desktop" in comments and output

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:24:03 +07:00
github-actions[bot] e0b1356afa ci: update server.json SHA256 for v1.31.3 [skip ci] 2026-04-22 00:21:57 +00:00
Devid HW 23a0b7a375 release: v1.31.3
- Version bump 1.31.2 → 1.31.3
- server.json identifier URL updated (SHA256 set by CI after build)
- CHANGELOG.md updated
2026-04-22 07:20:25 +07:00
Devid HW ac5ec453d3 docs: clean up public repo — remove IDE files, fix stale refs
- Untrack .claude/commands/ and .windsurf/workflows/ (personal IDE config)
- Fix .gitignore: scope /CLAUDE.md to root only, add .claude/, remove
  incorrect .github/workflows/ exclusion (CI needs these tracked)
- Rename docs/ANTIGRAVITY.md → docs/CLAUDE.md (Claude Desktop setup;
  "Antigravity" was an internal codename, confusing to public users)
- Fix stale repo name: masdevid/mt5-mcp → masdevid/mt5-quant in
  README.md, docs/CLAUDE.md, CURSOR.md, QUICKSTART.md, VSCODE.md, WINDSURF.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:20:20 +07:00
github-actions[bot] 9f6c67d0ef ci: update server.json SHA256 for v1.31.2 [skip ci] 2026-04-22 00:04:10 +00:00
Devid HW 262936a1d5 release: v1.31.2
- Version bump 1.31.1 → 1.31.2
- server.json identifier URL updated (SHA256 set by CI after build)
- CHANGELOG.md updated
2026-04-22 07:02:18 +07:00
Devid HW 0ea1e779e0 refactor: reduce handler boilerplate in analysis and experts modules
analysis.rs: extract required_str, ok_response, err_response, prepare_analysis,
deal_to_json, is_closed_trade helpers — 15 granular handlers reduced from
~14 lines each to 4 lines each (-293 lines total).

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 07:01:58 +07:00
github-actions[bot] a94d75823a ci: update server.json SHA256 for v1.31.1 [skip ci] 2026-04-21 23:32:59 +00:00
Devid HW c7e1a03e1f fix: registryType mcpbPackageType → mcpb
MCP registry API error: 'unsupported registry type: mcpbPackageType'
Schema examples show valid values: npm, pypi, oci, nuget, mcpb

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:31:16 +07:00
github-actions[bot] 23c67e80c5 ci: update server.json SHA256 for v1.31.1 [skip ci] 2026-04-21 23:29:49 +00:00
Devid HW 5db3c7c822 ci: fix mcp-publisher install — resolve URL from release assets API
v1.6.0 dropped the version number from asset filenames
(mcp-publisher_linux_amd64.tar.gz, not mcp-publisher_1.6.0_linux_amd64.tar.gz).
Look up the linux/amd64 .tar.gz URL directly from the GitHub releases API
instead of constructing it, so future renames don't break the workflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:27:06 +07:00
github-actions[bot] 5eb753584d ci: update server.json SHA256 for v1.31.1 [skip ci] 2026-04-21 23:18:49 +00:00
Devid HW 7c9eeef1c4 release: v1.31.1
- Version bump 1.31.0 → 1.31.1
- server.json identifier URL updated (SHA256 set by CI after build)
- CHANGELOG.md updated
2026-04-22 06:15:56 +07:00
Devid HW 20a2d3d6e4 ci: add release automation scripts and workflows
- scripts/release.sh: one-command local release (bump, changelog, tag, push)
- .github/workflows/release.yml: fix SHA256 auto-commit back to master,
  dynamic mcp-publisher version detection, cargo cache, security hardening
- .claude/commands/release.md: /release slash command
- .windsurf/workflows/release.md: streamlined to use release.sh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 06:15:38 +07:00
Devid HW ec2a22ed30 ci: handle mcp-publisher registryType bug gracefully
- Don't fail workflow if MCP publish fails
- Add clear manual publish instructions
- Reference web UI as alternative
2026-04-22 05:58:09 +07:00
Devid HW 94a8c42e68 fix: update registryType to mcpbPackageType
- The registry expects mcpbPackageType not mcpb
2026-04-22 05:52:20 +07:00
Devid HW 7e97086909 ci: add checkout step to mcp-publish job
- server.json is needed for mcp-publisher to work
2026-04-22 05:44:43 +07:00
Devid HW 7a8a8daeca ci: use GitHub OIDC for MCP authentication
- Replace non-existent --token-stdin with github-oidc method
- Add id-token: write permission for OIDC
- Remove MCP_PUBLISH_TOKEN (not needed with OIDC)
2026-04-22 05:39:46 +07:00
Devid HW ba04405454 ci: fix mcp-publisher download URL
- Change from wrong repo (mcp-publisher) to correct repo (registry)
- Use proper tar.gz format with versioned release URL
- Add tar extraction step
- Update manual install instructions with correct URL
2026-04-22 05:32:55 +07:00
Devid HW e40dbf63cf ci: fix mcp-publisher download with error handling
- Add curl -fsSL to fail on HTTP errors
- Add graceful skip if download fails
- Add manual publish instructions as fallback
2026-04-22 05:28:01 +07:00
Devid HW 0a2e076ad6 ci: rename release artifacts to mcp-mt5-quant-* format
- Change mt5-quant-macos-arm64.tar.gz -> mcp-mt5-quant-macos-arm64.tar.gz
- Change mt5-quant-linux-x64.tar.gz -> mcp-mt5-quant-linux-x64.tar.gz
- Update build-mcp-package job extraction paths
2026-04-22 05:22:02 +07:00
Devid HW 8f71f1629d docs: unify all docs to use single installation path ~/.local/bin/mt5-quant
- Update WINDSURF.md: Use registry name io.github.masdevid/mt5-quant
- Update CURSOR.md: Use ~/.local/bin/mt5-quant path
- Update QUICKSTART.md: Use standard path for all clients
- Update VSCODE.md: Use standard path
- Update setup.sh: Prefer ~/.local/bin/mt5-quant over project path
- Add fallback warning in setup.sh if project path used

Single source of truth: ~/.local/bin/mt5-quant
2026-04-22 05:17:20 +07:00
Devid HW ad17f9dba6 ci: add release-info job with Claude/Windsurf/Cursor commands
- Add final job that displays copy-paste registration commands
- Shows Claude Code, Windsurf, and VS Code/Cursor commands
- Includes release URLs and success banner
2026-04-22 05:07:27 +07:00
Devid HW 33186d276a ci: add automated MCP publishing and client registration docs
- Update release workflow with MCP_PUBLISH_TOKEN for automated publishing
- Remove OIDC requirement (using classic token)
- Add MCP client registration commands for Windsurf, Claude, VS Code/Cursor
- Add verification steps for post-release checking
2026-04-22 05:04:18 +07:00
Devid HW 3927343999 ci: update release workflow for manual triggers and MCP publish
- Add workflow_dispatch with version input for manual releases
- Remove tag-only condition - now works with manual triggers
- Use github.event.inputs.version || github.ref_name for flexibility
- Add MCP package artifact upload
- Add mcp-publish job with OIDC support instructions
2026-04-22 04:57:23 +07:00
Devid HW 6a5253186b ci: add release workflow for automated builds
- Build macOS and Linux binaries
- Create MCP package with SHA256
- Upload to GitHub releases
2026-04-22 04:50:16 +07:00
Devid HW 76dac96583 docs: update release workflow to use single installation path
- Use ~/.local/bin/mt5-quant as single installation location
- Remove redundant project URL references
- Simplify MCP registration for all clients (Claude, Windsurf, VS Code)
2026-04-22 04:48:56 +07:00
Devid HW 0bc410f613 feat: add Wine/MT5 debugging tools and update release workflow
- Add 9 debugging/diagnostics tools for Wine/MT5 crash investigation
- Update server.json with v1.30.0 and MCP package config
- Update README.md with 85 tools count and debugging section
- Update docs/MCP_TOOLS.md documentation
- Enhance release workflow with MCP packaging job
- Clean up mcp-package directory (now built in CI)
2026-04-22 04:41:41 +07:00
Devid HW 9be1296916 fix: shorten server.json description for MCP registry
Description must be <=100 characters for registry validation.
Previous: 96 chars -> Current: 82 chars
2026-04-20 03:04:17 +07:00
Devid HW 63c0470c6b fix: update server.json with real SHA256 and shorten description
- Description: 96 chars (was >100)
- SHA256: calculated from local MCP package build
2026-04-20 02:54:28 +07:00
Devid HW a1434914e9 chore: update release workflow and server.json for v1.30.0
- Update server.json: version 1.30.0, new download URL, updated description
- Add MCP package build job to release workflow
- Add SHA256 calculation for server.json
- Update release workflow to upload mcp-* package
2026-04-20 02:51:36 +07:00
Devid HW 6ce8808948 v1.30.0: Add 10 Wine/MT5 debugging tools
New debugging/diagnostics tools for crash investigation:
- diagnose_wine: Check Wine installation and prefix health
- get_mt5_logs: Get terminal/tester/metaeditor logs
- search_mt5_errors: Search logs for error patterns
- check_mt5_process: Check MT5 process status
- kill_mt5_process: Kill stuck MT5 processes
- check_system_resources: Check disk/memory/CPU
- validate_mt5_config: Validate MT5 configuration
- get_wine_prefix_info: Wine prefix details
- get_backtest_crash_info: Investigate backtest failures

Total tools: 85
Documentation updated in README.md and MCP_TOOLS.md
2026-04-20 02:25:07 +07:00
Devid HW 896aa6111e feat: auto-detect and register MCP on multiple platforms
- Add multi-platform MCP detection (Claude, Windsurf, Cursor, VS Code)
- Auto-unregister previous installations before reinstall
- Create platform-specific docs (CURSOR.md, VSCODE.md, ANTIGRAVITY.md)
- Update Windsurf config to use correct JSON path (~/.codeium/windsurf/mcp_config.json)
- Fix VS Code config format (uses 'servers' not 'mcpServers')
- Remove MT5_MCP_HOME requirement from MCP configs (binary auto-detects)
- Slim down README installation section with platform table
- Update QUICKSTART.md with all platform instructions
2026-04-20 00:40:41 +07:00
Devid aab34a1fde Add GitHub Actions workflow for Rust project 2026-04-19 10:16:03 +07:00
Devid HW 96c7e0a9c2 chore: update server.json for v1.29.1 release 2026-04-19 10:10:55 +07:00
Devid HW 0c82501e18 chore: bump version to 1.29.1 2026-04-19 10:10:02 +07:00
44 changed files with 8748 additions and 1111 deletions
+399
View File
@@ -0,0 +1,399 @@
name: Build and Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v1.32.0)'
required: true
type: string
jobs:
# ── Build binaries ───────────────────────────────────────────────────────────
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: macos-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: macos-cargo-
- name: Build release binary
run: cargo build --release
- name: Package binary
run: |
mkdir -p dist/mcp-mt5-quant-macos
cp target/release/mt5-quant dist/mcp-mt5-quant-macos/
cp -r config dist/mcp-mt5-quant-macos/
cp README.md dist/mcp-mt5-quant-macos/
cp -r docs dist/mcp-mt5-quant-macos/
cd dist
tar -czf mcp-mt5-quant-macos-arm64.tar.gz mcp-mt5-quant-macos
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: macos-binary
path: dist/mcp-mt5-quant-macos-arm64.tar.gz
build-linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: linux-cargo-
- name: Build release binary
run: cargo build --release
- name: Package binary
run: |
mkdir -p dist/mcp-mt5-quant-linux
cp target/release/mt5-quant dist/mcp-mt5-quant-linux/
cp -r config dist/mcp-mt5-quant-linux/
cp README.md dist/mcp-mt5-quant-linux/
cp -r docs dist/mcp-mt5-quant-linux/
cd dist
tar -czf mcp-mt5-quant-linux-x64.tar.gz mcp-mt5-quant-linux
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: linux-binary
path: dist/mcp-mt5-quant-linux-x64.tar.gz
# ── GitHub Release ───────────────────────────────────────────────────────────
release:
needs: [build-macos, build-linux]
runs-on: ubuntu-latest
permissions:
contents: write
env:
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
steps:
- uses: actions/checkout@v4
- name: Download macOS artifact
uses: actions/download-artifact@v4
with:
name: macos-binary
path: dist
- name: Download Linux artifact
uses: actions/download-artifact@v4
with:
name: linux-binary
path: dist
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_TAG }}
name: ${{ env.RELEASE_TAG }}
files: |
dist/mcp-mt5-quant-macos-arm64.tar.gz
dist/mcp-mt5-quant-linux-x64.tar.gz
draft: false
prerelease: false
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ── MCP Package ──────────────────────────────────────────────────────────────
# Builds the installable MCP package (binary + config + docs),
# computes its SHA256, and uploads to the release.
build-mcp-package:
needs: release
runs-on: macos-latest
permissions:
contents: write
outputs:
sha256: ${{ steps.compute-sha256.outputs.sha256 }}
env:
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
steps:
- uses: actions/checkout@v4
- name: Download macOS binary
uses: actions/download-artifact@v4
with:
name: macos-binary
path: dist
- name: Build MCP package
run: |
mkdir -p mcp-package/mcp-server/bin
cd dist && tar -xzf mcp-mt5-quant-macos-arm64.tar.gz && cd ..
cp dist/mcp-mt5-quant-macos/mt5-quant mcp-package/mcp-server/bin/
cp -r config mcp-package/
cp -r docs mcp-package/
cp README.md mcp-package/
cp server.json mcp-package/
cd mcp-package && tar -czf ../mcp-mt5-quant-macos-arm64.tar.gz . && cd ..
- name: Compute SHA256
id: compute-sha256
run: |
SHA256=$(shasum -a 256 mcp-mt5-quant-macos-arm64.tar.gz | awk '{print $1}')
echo "sha256=$SHA256" >> "$GITHUB_OUTPUT"
echo "MCP Package SHA256: $SHA256"
- name: Upload MCP package to release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_TAG }}
files: mcp-mt5-quant-macos-arm64.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload MCP package artifact
uses: actions/upload-artifact@v4
with:
name: mcp-package
path: mcp-mt5-quant-macos-arm64.tar.gz
# ── Update server.json SHA256 ────────────────────────────────────────────────
# Commits the computed SHA256 back to master so server.json always
# reflects the latest published package hash.
update-server-json:
needs: build-mcp-package
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
ref: master
fetch-depth: 1
token: ${{ secrets.GITHUB_TOKEN }}
- name: Patch server.json
env:
PKG_SHA256: ${{ needs.build-mcp-package.outputs.sha256 }}
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
run: |
VERSION="${RELEASE_TAG#v}"
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
VERSION="$VERSION" PKG_SHA256="$PKG_SHA256" python3 - <<'PYEOF'
import json, os, re
sha256 = os.environ['PKG_SHA256']
version = os.environ['VERSION']
with open('server.json') as f:
data = json.load(f)
data['version'] = version
for pkg in data.get('packages', []):
pkg['version'] = version
pkg['fileSha256'] = sha256
if 'identifier' in pkg:
pkg['identifier'] = re.sub(
r'/v[0-9]+\.[0-9]+\.[0-9]+/',
f'/v{version}/',
pkg['identifier']
)
with open('server.json', 'w') as f:
json.dump(data, f, indent=2)
f.write('\n')
print(f"Patched: version={version}, sha256={sha256[:16]}...")
PYEOF
- name: Commit server.json
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add server.json
if git diff --cached --quiet; then
echo "server.json unchanged — no commit needed"
else
git commit -m "ci: update server.json SHA256 for v${VERSION} [skip ci]"
git push origin master
echo "Committed server.json update"
fi
# ── MCP Registry Publish ─────────────────────────────────────────────────────
mcp-publish:
needs: [build-mcp-package, update-server-json]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # Required for GitHub OIDC
steps:
- uses: actions/checkout@v4
with:
ref: master # Pull master so server.json has the correct SHA256
- name: Download MCP package
uses: actions/download-artifact@v4
with:
name: mcp-package
path: .
- name: Install mcp-publisher
id: install-publisher
run: |
# Fetch the download URL for linux_amd64 directly from the release assets API
# (asset name format changed in v1.6.0 — look it up rather than constructing it)
URL=$(curl -sf \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/modelcontextprotocol/registry/releases/latest" \
| python3 -c "
import sys, json
r = json.load(sys.stdin)
tag = r['tag_name']
assets = r.get('assets', [])
match = next((a['browser_download_url'] for a in assets
if 'linux' in a['name'] and 'amd64' in a['name']
and a['name'].endswith('.tar.gz')
and 'sbom' not in a['name']
and 'sigstore' not in a['name']), None)
if match:
print(match)
else:
print(f'https://github.com/modelcontextprotocol/registry/releases/download/{tag}/mcp-publisher_linux_amd64.tar.gz', file=sys.stderr)
sys.exit(1)
" 2>/dev/null)
if [[ -z "$URL" ]]; then
echo "installed=false" >> "$GITHUB_OUTPUT"
echo "Could not resolve mcp-publisher download URL"
else
echo "Downloading: $URL"
if curl -fsSL -o mcp-publisher.tar.gz "$URL"; then
tar -xzf mcp-publisher.tar.gz 2>/dev/null || true
BINARY=$(find . -maxdepth 3 -name "mcp-publisher" ! -path "./.git/*" | head -1)
if [[ -n "$BINARY" && -f "$BINARY" ]]; then
chmod +x "$BINARY"
sudo mv "$BINARY" /usr/local/bin/mcp-publisher
echo "installed=true" >> "$GITHUB_OUTPUT"
echo "Installed: $(mcp-publisher --version 2>/dev/null || echo 'ok')"
else
echo "installed=false" >> "$GITHUB_OUTPUT"
echo "Binary not found in archive"
fi
rm -f mcp-publisher.tar.gz
else
echo "installed=false" >> "$GITHUB_OUTPUT"
echo "Download failed: $URL"
fi
fi
- name: Validate server.json before publish
id: validate
run: |
python3 - <<'PYEOF'
import json, sys
with open('server.json') as f:
data = json.load(f)
print(f" name: {data.get('name')}")
print(f" version: {data.get('version')}")
ok = True
for pkg in data.get('packages', []):
sha = pkg.get('fileSha256', '')
ident = pkg.get('identifier', '')
print(f" sha256: {sha}")
print(f" url: {ident}")
if sha in ('', 'TBD_CI_WILL_UPDATE', 'TBD_UPDATED_BY_CI'):
print('ERROR: fileSha256 is a placeholder — publish aborted')
ok = False
sys.exit(0 if ok else 1)
PYEOF
- name: Publish to MCP Registry
if: steps.install-publisher.outputs.installed == 'true' && steps.validate.outcome == 'success'
run: |
echo "=== Authenticating with GitHub OIDC ==="
mcp-publisher login github-oidc || {
echo "OIDC login failed"
exit 1
}
echo "=== Publishing ==="
mcp-publisher publish && echo "✓ Published to MCP Registry" || {
echo ""
echo "══════════════════════════════════════════════════════════"
echo " AUTOMATED MCP PUBLISH FAILED — manual steps:"
echo "══════════════════════════════════════════════════════════"
echo " 1. Download: https://github.com/modelcontextprotocol/registry/releases"
echo " 2. ./mcp-publisher login github"
echo " 3. ./mcp-publisher publish"
echo " Web UI: https://registry.modelcontextprotocol.io"
echo "══════════════════════════════════════════════════════════"
exit 0 # Don't fail the workflow — release succeeded
}
- name: Skip notice
if: steps.install-publisher.outputs.installed != 'true'
run: |
echo "mcp-publisher not installed — skipping automated publish"
echo "Manual: https://registry.modelcontextprotocol.io"
# ── Release Summary ──────────────────────────────────────────────────────────
release-info:
needs: [mcp-publish]
runs-on: ubuntu-latest
env:
RELEASE_TAG: ${{ github.event.inputs.version || github.ref_name }}
steps:
- name: Summary
run: |
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Release ${RELEASE_TAG} complete! "
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo "GitHub Release:"
echo " https://github.com/masdevid/mt5-quant/releases/tag/${RELEASE_TAG}"
echo ""
echo "MCP Registry: io.github.masdevid/mt5-quant"
echo ""
echo "── MCP Client Registration ──────────────────────────────────"
echo ""
echo "Claude Code:"
echo " claude mcp add io.github.masdevid/mt5-quant -- ~/.local/bin/mt5-quant"
echo ""
echo "Windsurf (~/.codeium/windsurf/mcp_config.json):"
echo ' {"mcpServers": {"io.github.masdevid/mt5-quant": {"command": "~/.local/bin/mt5-quant"}}}'
echo ""
echo "Cursor / VS Code (~/.cursor/mcp.json):"
echo ' {"mcpServers": {"mt5-quant": {"command": "~/.local/bin/mt5-quant"}}}'
echo ""
echo "─────────────────────────────────────────────────────────────"
+22
View File
@@ -0,0 +1,22 @@
name: Rust
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
+2 -4
View File
@@ -20,14 +20,12 @@ config/baseline.json
config/backtest_history.json
# Claude Code (personal, not for repo)
CLAUDE.md
/CLAUDE.md
.claude/
# Windsurf (local workflows, not for repo)
.windsurf/
# GitHub Actions (local workflows, not for repo)
.github/workflows/
# macOS
.DS_Store
+42
View File
@@ -0,0 +1,42 @@
# Changelog
## [1.31.5] — 2026-04-22
- feat: add check_update and update tools with background auto-check
- release: v1.31.4
- fix: update all scripts for correctness and consistency
- release: v1.31.3
- docs: clean up public repo — remove IDE files, fix stale refs
- release: v1.31.2
- refactor: reduce handler boilerplate in analysis and experts modules
- fix: registryType mcpbPackageType → mcpb
## [1.31.4] — 2026-04-22
- fix: update all scripts for correctness and consistency
- release: v1.31.3
- docs: clean up public repo — remove IDE files, fix stale refs
- release: v1.31.2
- refactor: reduce handler boilerplate in analysis and experts modules
- fix: registryType mcpbPackageType → mcpb
## [1.31.3] — 2026-04-22
- docs: clean up public repo — remove IDE files, fix stale refs
- release: v1.31.2
- refactor: reduce handler boilerplate in analysis and experts modules
- fix: registryType mcpbPackageType → mcpb
## [1.31.2] — 2026-04-22
- refactor: reduce handler boilerplate in analysis and experts modules
- fix: registryType mcpbPackageType → mcpb
## [1.31.1] — 2026-04-22
- Minor improvements and bug fixes
Generated
+1 -1
View File
@@ -481,7 +481,7 @@ dependencies = [
[[package]]
name = "mt5-quant"
version = "1.29.0"
version = "1.32.0"
dependencies = [
"anyhow",
"base64",
+10 -2
View File
@@ -1,9 +1,17 @@
[package]
name = "mt5-quant"
version = "1.29.0"
version = "1.32.0"
edition = "2021"
description = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP"
description = "MCP server for MT5 strategy development on macOS/Linux"
authors = ["masdevid <masdevid@example.com>"]
license = "MIT"
repository = "https://github.com/masdevid/mt5-quant"
readme = "README.md"
keywords = ["mt5", "mql5", "trading", "mcp", "backtest"]
categories = ["finance", "development-tools"]
homepage = "https://github.com/masdevid/mt5-quant"
documentation = "https://github.com/masdevid/mt5-quant"
maintenance = { status = "actively-developed" }
[[bin]]
name = "mt5-quant"
+135 -56
View File
@@ -1,6 +1,6 @@
# MT5-Quant
**MCP server for MT5 strategy development on macOS/Linux.** 57 tools to compile, backtest, analyze, optimize, and manage MQL5 Expert Advisors — no Windows required.
**MCP server for MT5 strategy development on macOS/Linux.** 89 tools to compile, backtest, analyze, optimize, debug crashes, and manage MQL5 Expert Advisors — no Windows required.
```
You: "Backtest MyEA Jan-Mar, what caused the February drawdown?"
@@ -13,70 +13,67 @@ 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.
| | MT5-Quant | Others |
|---|---|---|
| **Platform** | macOS/Linux native | Windows only |
| **Backtest pipeline** | ✅ Full (compile → run → analyze) | ✅ Via MT5 Python package |
| **Deal-level analytics** | ✅ 19 dimensions, DB-backed | ❌ |
| **Report organization** | ✅ SQLite (reports + deals) + search + history | ❌ |
| **MQL5 compilation** | ✅ Headless (MetaEditor via Wine, no GUI) | ⚠️ Via GUI or terminal |
| **Optimization** | ✅ Background + results parsing + .set generation | ⚠️ Terminal only, no parsing |
| **Crash debugging** | ✅ Wine/MT5 diagnostics | ❌ |
**Others** typically run on Windows using the [MetaTrader5 Python package](https://pypi.org/project/MetaTrader5/), providing full terminal operations. MT5-Quant fills the gap: **organizing backtest reports, extracting deal-level insights, and managing optimization workflows** — none of which MT5 or its Python API expose natively.
## Why Rust
**MT5-Quant is built entirely in Rust** — one static binary, zero dependencies, instant startup.
| | Rust | Python/Node |
|---|---|---|
| **Startup** | ~10ms | 200500ms |
| **Deploy** | Single binary | venv + pip/npm |
| **Memory** | <50MB, no GC pauses | Unpredictable spikes |
| **Safety** | Compile-time guaranteed | Runtime exceptions |
**Why this matters for trading:**
- **No garbage collection** — Process 100k+ deal rows without GC pauses during live analysis
- **Async via Tokio** — Handle multiple tool calls concurrently (poll status while streaming logs)
- **Cross-platform** — Same source compiles to native macOS arm64 and Linux x86_64
- **Type-safe pipelines** — MQL5 compilation, Wine path handling, SQLite queries: all checked at compile time
## Quick Install
### 1. Download & Setup
### Option 1: Pre-built Binary (Recommended)
```bash
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
tar -xzf mt5.tar.gz
bash scripts/setup.sh
```
### 2. Register MCP Server
#### Claude Code
### Option 2: Cargo Install (if you have Rust)
```bash
# Navigate to your project directory first
cd /path/to/your/mt5-quant
# Register MCP server (requires absolute path)
claude mcp add MT5-Quant -- $(pwd)/mt5-quant
# Verify installation
claude mcp list
cargo install mt5-quant
mt5-quant --help
```
#### Windsurf
Compiles from source. Takes 25 minutes. Requires Rust 1.70+.
Add to `~/.windsurf/config.yaml`:
### Register MCP Server
```yaml
mcpServers:
mt5-quant:
command: /absolute/path/to/mt5-quant
env:
MT5_MCP_HOME: /absolute/path/to/mt5-quant
```
| Platform | Command / Config | Docs |
|----------|------------------|------|
| **Claude Code** | `claude mcp add mt5-quant -- $(pwd)/mt5-quant` | [Setup →](docs/QUICKSTART.md) |
| **Windsurf** | Edit `~/.codeium/windsurf/mcp_config.json` | [WINDSURF.md →](docs/WINDSURF.md) |
| **Cursor** | Edit `~/.cursor/mcp.json` or use Settings → MCP | [CURSOR.md →](docs/CURSOR.md) |
| **VS Code** | Edit `.vscode/mcp.json` or run `MCP: Add Server` | [VSCODE.md →](docs/VSCODE.md) |
| **Claude Desktop** | Agent Panel → ... → MCP Servers → Edit configuration | [CLAUDE.md →](docs/CLAUDE.md) |
Or use the config command:
```bash
# Get absolute path
which mt5-quant
# Add to Windsurf config
cat >> ~/.windsurf/config.yaml << EOF
mcpServers:
mt5-quant:
command: $(which mt5-quant)
env:
MT5_MCP_HOME: $(dirname $(which mt5-quant))
EOF
```
**[Full Setup →](docs/QUICKSTART.md)**
> **Note:** MCP servers require **absolute paths**. Use `$(pwd)` or full path like `/Users/name/mt5-quant/mt5-quant`, not relative paths like `./mt5-quant`.
> **Note:** Use absolute paths like `/Users/name/mt5-quant/mt5-quant` or `$(pwd)/mt5-quant`, not relative paths like `./mt5-quant`.
## Quick Start
@@ -91,22 +88,30 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
| Doc | Purpose |
|-----|---------|
| [QUICKSTART.md](docs/QUICKSTART.md) | Complete setup for macOS/Linux |
| [WINDSURF.md](docs/WINDSURF.md) | Windsurf IDE setup |
| [CURSOR.md](docs/CURSOR.md) | Cursor IDE setup |
| [VSCODE.md](docs/VSCODE.md) | VS Code setup |
| [CLAUDE.md](docs/CLAUDE.md) | Claude Desktop setup |
| [CONFIG.md](docs/CONFIG.md) | Configuration reference |
| [WINDSURF.md](docs/WINDSURF.md) | Windsurf IDE integration |
| [TOOLS.md](docs/MCP_TOOLS.md) | All 57 tools documented |
| [TOOLS.md](docs/MCP_TOOLS.md) | All 89 tools documented |
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design and internals |
| [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Common issues |
| [REMOTE_AGENTS.md](docs/REMOTE_AGENTS.md) | Linux optimization agents |
## MCP Tools (57)
## 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 |
@@ -114,6 +119,7 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
| `list_indicators` | List all indicators in MQL5/Indicators directory |
| `list_scripts` | List all scripts in MQL5/Scripts directory |
| `healthcheck` | Quick server health check |
| `list_symbols` | List all available symbols in MT5 terminal |
### Granular Analytics (individual analysis)
@@ -130,12 +136,26 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
Use these for targeted analysis, or `analyze_report` to run all at once.
### Deal-Level Analytics (New)
| Tool | Description |
|------|-------------|
| `list_deals` | List individual deals with filters (type, profit range, volume, dates) |
| `search_deals_by_comment` | Full-text search in deal comments (e.g., "Layer #3") |
| `search_deals_by_magic` | Filter deals by EA magic number |
| `analyze_profit_distribution` | Profit histogram: small/medium/large wins and losses |
| `analyze_time_performance` | Performance by hour of day and day of week |
| `analyze_hold_time_distribution` | Hold time buckets + correlation with profit |
| `analyze_layer_performance` | Grid/martingale layer analysis from comments |
| `analyze_volume_vs_profit` | Volume correlation + performance by lot size |
| `analyze_costs` | Commission and swap impact on profitability |
| `analyze_efficiency` | Profit per hour/day, annualized return, trade frequency |
### Monitoring
| Tool | Description |
|------|-------------|
| `verify_setup` | Check Wine/MT5 paths, Wine version, and EA/set file counts |
| `get_backtest_status` | Check live progress of a running backtest pipeline |
| `get_optimization_status` | Check live state of a background optimization job |
| `list_jobs` | All optimization jobs with compact status in one call |
@@ -146,8 +166,17 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
| `list_reports` | Compact table of all runs with key metrics — no full analysis needed |
| `get_latest_report` | Get most recent report with optional equity chart |
| `search_reports` | Find reports by EA, symbol, date range, or profit criteria |
| `get_report_by_id` | Get specific report by ID with equity chart |
| `get_reports_summary` | Aggregate stats: counts, averages, pass rates |
| `get_best_reports` | Top N reports sorted by any metric (profit factor, drawdown, etc.) |
| `search_reports_by_tags` | Find reports by tags |
| `search_reports_by_date_range` | Query by backtest date range |
| `search_reports_by_notes` | Full-text search in report notes |
| `get_reports_by_set_file` | Find all reports using a specific .set file |
| `get_comparable_reports` | Find comparable reports (same EA/symbol/timeframe) |
| `tail_log` | Read last N lines of any log; `filter=errors` to see only failures |
| `prune_reports` | Delete old report directories, keep last N (skips `_opt` dirs) |
| `promote_to_baseline` | Write a history entry or report to `baseline.json` for compare_baseline |
### History & baseline
@@ -157,7 +186,6 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
| `archive_all_reports` | Bulk-archive all report dirs then optionally delete them; keeps N newest safe |
| `get_history` | Query history with filters (EA, symbol, verdict, profit, DD) and sort options |
| `annotate_history` | Attach verdict / notes / tags to any history entry |
| `promote_to_baseline` | Write a history entry or report to `baseline.json` for compare_baseline |
### Cache management
@@ -175,6 +203,22 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
| `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 |
@@ -224,14 +268,49 @@ Full schema: [docs/MCP_TOOLS.md](docs/MCP_TOOLS.md)
Run `verify_setup` from Claude first — it checks all paths and returns actionable hints.
For crashes or unexplained failures during backtest/compile/optimization:
- `diagnose_wine` — Check Wine installation and prefix health
- `search_mt5_errors` — Find crash causes in logs
- `check_mt5_process` + `kill_mt5_process` — Detect and kill stuck processes
- `get_backtest_crash_info` — Investigate failed backtest reports
**[Full Troubleshooting Guide →](docs/TROUBLESHOOTING.md)**
---
## 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
View File
@@ -1,14 +1,12 @@
# Architecture Deep Dive
# Architecture
## Design Philosophy
## Design Principles
**Deal-level over aggregate.** MT5's built-in HTML report gives you: profit, profit factor, max DD%, trade count. That's it. You cannot tell from those numbers whether a drawdown was caused by overleveraged locking, a bad entry during a news spike, or a grid that reached L8 in a trending market.
**Deal-level over aggregate.** MT5's HTML report gives you: profit, profit factor, max DD%, trade count. MT5-Quant extracts every individual deal — entry price, exit price, P/L, comment string — to reconstruct what happened during each loss event. Result: `analysis.json`, AI-readable and diffable between runs.
MT5-Quant extracts every individual deal — entry price, exit price, P/L, comment string — and reconstructs what was happening when each loss event occurred. The `analysis.json` artifact is the result: AI-readable, stable schema, diffable between runs.
**Pipeline idempotency.** MT5 caches aggressively (`.ex5` binaries, `.set` files, `terminal.ini` flags). The pipeline invalidates all cache before every run to prevent stale results.
**Pipeline idempotency.** MT5 caches aggressively. Cached `.ex5` binaries, cached `.set` files, stale `terminal.ini` flags. The pipeline exists specifically to invalidate all of these before every run. A backtest result that came from a cached EA binary is wrong in a way that's impossible to detect without the cache clear step.
**Background isolation for optimization.** Genetic optimizations run 2-6 hours. Running them inside a parent process that can be killed (Claude task runner, SSH session, terminal) corrupts the MT5 optimization state. The only correct pattern is `nohup + disown` — fully detached from all parent process trees.
**Background isolation.** Genetic optimizations run 2-6 hours. The `nohup + disown` pattern prevents corruption if the parent process (SSH, Claude runner) is killed.
---
@@ -25,24 +23,26 @@ MT5-Quant/
│ │ ├── metrics.rs # Metrics parsing from HTML/XML
│ │ └── report.rs # Report, PipelineMetadata, etc.
│ ├── analytics/ # Report extraction & analysis (migrated from Python)
│ │ ├── extract.rs # HTML/XML report parser → metrics.json + deals.csv
│ │ ├── extract.rs # HTML/XML report parser → metrics.json (deals → DB)
│ │ └── analyze.rs # Deal-level analysis engine → analysis.json
│ ├── compile/ # MQL5 compilation
│ │ └── mql_compiler.rs # MetaEditor wrapper (Wine/CrossOver)
│ ├── pipeline/ # Backtest orchestration
│ │ ├── backtest.rs # 5-stage pipeline (COMPILE→CLEAN→BACKTEST→EXTRACT→ANALYZE)
│ │ └── stages.rs # Pipeline stage definitions
│ ├── storage/ # SQLite persistence
│ │ └── database.rs # ReportDb: reports table + deals table
│ └── tools/ # MCP tool definitions
│ ├── definitions/ # Tool schemas (9 domain modules, 43 tools)
│ ├── definitions/ # Tool schemas (9 domain modules, 90 tools)
│ │ ├── mod.rs
│ │ ├── analytics.rs # 9 analysis tools
│ │ ├── backtest.rs # 4 backtest tools
│ │ ├── analytics.rs # 19 analysis tools (DB-backed)
│ │ ├── backtest.rs # 7 backtest tools
│ │ ├── baseline.rs # 1 baseline tool
│ │ ├── experts.rs # 4 EA/indicator/script tools
│ │ ├── experts.rs # 9 EA/indicator/script tools
│ │ ├── optimization.rs # 4 optimization tools
│ │ ├── reports.rs # 11 report management tools
│ │ ├── reports.rs # 20 report management tools
│ │ ├── setfiles.rs # 8 .set file tools
│ │ └── system.rs # 3 system tools
│ │ └── system.rs # 6 system tools
│ └── handlers/ # Tool dispatch (9 domain modules)
│ ├── mod.rs
│ ├── analysis.rs
@@ -59,11 +59,6 @@ MT5-Quant/
│ ├── build-rust.sh # Rust build script
│ └── optimize.sh # Genetic optimization launcher (nohup + disown)
├── analytics/ # Legacy Python (reference only)
│ ├── extract.py
│ ├── analyze.py
│ └── optimize_parser.py
├── config/
│ ├── mt5-quant.example.yaml # Template config
│ └── mt5-quant.yaml # Live config (gitignored)
@@ -152,17 +147,26 @@ MT5 runs in headless mode, writes the report, and exits.
---
### Stage 4: EXTRACT
### Stage 4: EXTRACT + STORE
Single HTML/XML parse pass that produces three artifacts:
Single HTML/XML parse pass. Deals go directly into the SQLite database; the raw report file is deleted afterwards.
```rust
// src/analytics/extract.rs
let extractor = ReportExtractor::new();
let result = extractor.extract(&report_path, &output_dir)?;
// → metrics.json (aggregate summary)
// → deals.csv (all deals, 13 columns)
// → deals.json (same data, JSON)
// → metrics.json (aggregate summary — written to report_dir)
// HTML report deleted after extraction
// src/storage/database.rs
db.insert_deals(&report_id, &result.deals)?;
// → deals table in SQLite (all deals, keyed by report_id)
```
On-demand CSV export is available via the `export_deals_csv` tool:
```
export_deals_csv(report_id: "20260422_051041_DPS21_XAUUSDc_M5_1")
// → report_dir/deals.csv (written only when explicitly requested)
```
**Why single-pass?** MT5 HTML reports are large (1-5MB for 14-month tests). Each regex pass over the file takes ~200ms. The old pipeline ran 5 separate grep/regex passes. The Rust implementation uses a single-pass parser: 5× faster and no partial-read inconsistencies.
@@ -182,12 +186,13 @@ if ext == "xml" || path.ends_with(".htm.xml") {
}
```
**Deal columns (13):**
**Deal columns (stored in DB):**
```
Time | Type | Direction | Volume | Price | S/L | T/P | Profit | Balance | Comment | Order | Magic | Entry
time | deal | symbol | deal_type | entry | volume | price | order_id
commission | swap | profit | balance | comment | magic
```
The `Comment` column is the key to grid analytics. The EA writes `"Layer #3"`, `"Locking Total"`, `"Zombie Exit"` etc. Pattern matching on comments reconstructs which position was at which layer.
The `comment` column is the key to grid analytics. The EA writes `"Layer #3"`, `"Locking Total"`, `"Zombie Exit"` etc. Pattern matching on comments reconstructs which position was at which layer.
---
@@ -231,15 +236,6 @@ Built-in profiles:
| `trend` | — | `magic` | 240 | breakeven, trailing, partial, tp, sl |
| `hedge` | — | `magic+direction` | 120 | tp, sl, net_close, partial |
#### Entry points (after `pip install -e .`)
```bash
mt5-analyze deals.csv # generic
mt5-analyze-grid deals.csv # grid / martingale (default in pipeline)
mt5-analyze-scalper deals.csv # scalper
mt5-analyze-trend deals.csv # trend following
mt5-analyze-hedge deals.csv # hedging
```
#### Analytics functions
@@ -416,7 +412,7 @@ Then set `DISPLAY=:99` in MT5-Quant's environment config.
**Comment-based analytics:**
- Strategy-specific analytics (depth histogram, exit reason, DD cause) depend on EA comment strings. EAs that don't write structured comments will get `generic` profile results — summary metrics, session breakdown, streaks, and direction bias all still work; only keyword-classified fields fall back to `"unknown"` or profit-sign.
- Custom comment patterns can be supported by adding a new entry to `PROFILES` in `analytics/analyze.py` — no other code changes needed.
- Custom comment patterns can be supported by adding a new entry to `PROFILES` in `src/analytics/analyze.rs`.
**Single MT5 instance:**
- MT5 is single-instance per Windows drive. Two backtests cannot run simultaneously on the same Wine prefix. Parallelism requires multiple Wine prefixes (separate installations).
@@ -425,34 +421,8 @@ Then set `DISPLAY=:99` in MT5-Quant's environment config.
## Claude Code Integration
`setup.sh --claude-code` generates two files that give Claude persistent context about the user's trading setup:
### `config/CLAUDE.template.md`
A project-level CLAUDE.md template the user copies to their EA project root. Encodes:
`setup.sh` generates `config/CLAUDE.template.md` — a project-level template encoding:
- MT5-Quant tool names and when to use them
- Baseline tracking policy (never call something an improvement without comparing to `baseline.json`)
- Symbol name reminder (broker-specific suffix matters — `XAUUSD.cent``XAUUSD`)
- Backtest and optimization constraints (model 0, single instance, UTF-16LE .set files)
### `.claude/hooks/user-prompt-submit.sh`
A Claude Code hook that runs before every prompt submission. Reads `config/baseline.json` and outputs a JSON context block:
```json
{"context": "## Production Baseline (config/baseline.json)\n..."}
```
Claude Code injects this into the system context for every conversation turn. The result: Claude always knows the current production metrics without the user having to paste them.
**Hook execution path:**
```
User types prompt
→ user-prompt-submit.sh executes
→ reads config/baseline.json
→ outputs {"context": "..."} to stdout
→ Claude Code prepends to system context
→ Claude sees baseline in every prompt
```
**Graceful degradation:** If `baseline.json` doesn't exist or is malformed, the hook exits 0 silently — no prompt is blocked. The baseline section simply doesn't appear until the user creates the file.
- Baseline tracking policy (compare to `baseline.json` before calling improvements)
- Symbol name reminders (`XAUUSD.cent``XAUUSD`)
- Backtest constraints (model 0, UTF-16LE .set files)
+142
View File
@@ -0,0 +1,142 @@
# Claude Desktop MCP Integration Setup
## Quick Setup
### Option 1: Install via MCP Registry (Recommended)
Search for `mt5-quant` in Claude Desktop's MCP manager, or add directly to your config.
### Option 2: Download Prebuilt Binary
```bash
# macOS (Apple Silicon)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 3: Build from Source
```bash
git clone https://github.com/masdevid/mt5-quant
cd mt5-quant
cargo build --release
```
## Configure Claude Desktop
### Step 1: Open MCP Configuration
Edit `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows).
### Step 2: Add mt5-quant
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/absolute/path/to/mt5-quant"
}
}
}
```
### Step 3: Reload and Verify
1. Save the file
2. **Restart Claude Desktop**
3. In a new conversation, type: `What tools do you have access to?`
4. The agent should list MT5 tools like `verify_setup`, `run_backtest`, etc.
## Full Example Configuration
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/name/mt5-quant/mcp-server/bin/mt5-quant"
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${env:GITHUB_TOKEN}"
}
}
}
}
```
**Path notes:**
- Prebuilt binary: `/Users/name/mt5-quant/mcp-server/bin/mt5-quant` (extracted from release tarball)
- Dev build: `/Users/name/mt5-quant/target/release/mt5-quant` (after `cargo build --release`)
## Environment Variables
Claude Desktop supports `${env:VAR_NAME}` syntax for environment variable substitution:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/name/mt5-quant/mcp-server/bin/mt5-quant",
"env": {
"MT5_MCP_HOME": "${env:HOME}/.config/mt5-quant"
}
}
}
}
```
## Verify Setup
In a Claude Desktop conversation, type:
```
Run verify_setup
```
Expected output:
```
Wine: /Applications/MetaTrader 5.app/.../wine64
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
Display: gui
Arch: arch -x86_64
```
## Troubleshooting
### "Tool not found" or server not appearing
1. Double-check the absolute path in `claude_desktop_config.json`
2. Ensure the binary has execute permissions: `chmod +x /path/to/mt5-quant`
3. Restart Claude Desktop completely
4. Check Claude Desktop logs: `~/Library/Logs/Claude/` (macOS)
### JSON syntax errors
1. Validate the JSON at [jsonlint.com](https://jsonlint.com)
2. Ensure no trailing commas
3. Use absolute paths (not `~` or relative paths)
### Agent hallucinating tool parameters
1. Verify the tool is available: "List your available MCP tools"
2. Start a new conversation
3. Be explicit: "Use the `run_backtest` tool with expert=MyEA"
## Configuration Location
| Platform | Path |
|----------|------|
| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` |
| Windows | `%APPDATA%\Claude\claude_desktop_config.json` |
## Resources
- [Claude Desktop MCP Documentation](https://docs.anthropic.com/en/docs/claude-code/mcp)
- [MCP Server Reference](https://github.com/modelcontextprotocol/servers)
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
+129
View File
@@ -0,0 +1,129 @@
# Cursor MCP Integration Setup
## Quick Setup
### Option 1: Download Prebuilt Binary (Recommended)
```bash
# macOS (Apple Silicon)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
tar -xzf mt5.tar.gz
# Linux (x64)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
tar -xzf mt5.tar.gz
```
### Option 2: Build from Source
```bash
cargo build --release
```
## Configure Cursor
### Method 1: Settings UI (Recommended)
1. Open Cursor Settings (`Cmd/Ctrl + ,`)
2. Navigate to **Features****MCP**
3. Click **Add Custom MCP**
4. Enter:
- **Name**: `mt5-quant`
- **Command**: `/path/to/mt5-quant/mcp-server/bin/mt5-quant`
- **Type**: `stdio`
### Method 2: Edit mcp.json Directly
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
```json
{
"mcpServers": {
"mt5-quant": {
"type": "stdio",
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
```
Create the file if it doesn't exist:
```bash
mkdir -p ~/.cursor
cat > ~/.cursor/mcp.json << 'EOF'
{
"mcpServers": {
"mt5-quant": {
"type": "stdio",
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
EOF
```
## Verify Setup
In Cursor chat, type:
```
Run verify_setup
```
Expected output:
```
Wine: /Applications/MetaTrader 5.app/.../wine64
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
Display: gui
Arch: arch -x86_64
```
## Configuration Locations
| Scope | Path | Use Case |
|-------|------|----------|
| Global | `~/.cursor/mcp.json` | Available in all projects |
| Project | `.cursor/mcp.json` | Project-specific tools |
## Troubleshooting
### MCP server not appearing
1. Check MCP panel in Cursor Settings
2. Verify the path is absolute (not relative)
3. Test binary: `/path/to/mt5-quant/mcp-server/bin/mt5-quant --help`
4. View MCP logs: Output panel → select "MCP" from dropdown
### Config interpolation
Cursor supports variable substitution in `mcp.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "${userHome}/mt5-quant/mcp-server/bin/mt5-quant",
"args": ["--config", "${workspaceFolder}/config.yaml"]
}
}
}
```
Available variables:
- `${userHome}` - Home directory
- `${workspaceFolder}` - Project root
- `${workspaceFolderBasename}` - Project folder name
- `${env:VAR_NAME}` - Environment variable
### Tool not found errors
If the agent says "Tool not found":
1. Check the server is enabled in MCP settings
2. Try disabling and re-enabling the server
3. Restart Cursor
## Resources
- [Cursor MCP Documentation](https://cursor.com/docs/context/mcp)
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
+1878 -14
View File
File diff suppressed because it is too large Load Diff
+69 -15
View File
@@ -6,19 +6,19 @@
```bash
# macOS (Apple Silicon)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
tar -xzf mt5.tar.gz
# Linux (x64)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
tar -xzf mt5.tar.gz
```
### Option B: Build from Source
```bash
git clone https://github.com/masdevid/mt5-mcp
cd mt5-mcp
git clone https://github.com/masdevid/mt5-quant
cd mt5-quant
bash scripts/build-rust.sh
```
@@ -81,7 +81,8 @@ terminal_dir: "~/Library/Application Support/net.metaquotes.wine.metatrader5/dri
### Claude Code
```bash
claude mcp add MT5-Quant -- /path/to/mt5-quant/target/release/mt5-quant
claude mcp add io.github.masdevid/mt5-quant -- ~/.local/bin/mt5-quant
# Or: claude mcp add mt5-quant -- $(pwd)/mcp-server/bin/mt5-quant
```
Verify:
@@ -91,15 +92,68 @@ claude mcp list
### Windsurf
Add to `~/.windsurf/config.yaml`:
```yaml
mcpServers:
mt5-quant:
command: /path/to/mt5-quant
env:
MT5_MCP_HOME: /path/to/mt5-mcp
Add to `~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
```
### Cursor
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
```
Or use Settings → MCP → Add Custom MCP.
### VS Code
Add to `.vscode/mcp.json` in your workspace:
```json
{
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
```
Or run `MCP: Add Server` from Command Palette.
### Claude Desktop
1. Open Agent panel → Click "..." menu → MCP Servers
2. Click "Manage MCP Servers"
3. Click "View raw config" or "Edit configuration"
4. Add to `mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
```
5. Restart Claude Desktop to apply changes
## 5. Verify Setup
```bash
@@ -135,4 +189,4 @@ The AI will:
---
**Next:** See [TOOLS.md](TOOLS.md) for all 43 available tools.
**Next:** See [TOOLS.md](TOOLS.md) for all 89 available tools.
+17
View File
@@ -82,6 +82,23 @@ Check `<terminal_dir>/MQL5/Logs/`:
- Check `.set` file values appropriate for symbol/broker
- Confirm `OnInit()` returns `INIT_SUCCEEDED` (MT5 Journal tab)
## Analytics Tools: "No reports in DB" or "Report not found"
Analytics tools load deals from the **SQLite database**, not from CSV files on disk. Resolution order:
1. `report_id` (preferred) — ID from `list_reports`
2. `report_dir` (legacy) — filesystem path, looks up matching DB entry
3. No args — uses the latest report automatically
Pre-DB reports (before this version) won't be found by `report_dir`. Re-run the backtest to get a DB-backed report.
**`deals.csv` is no longer written automatically.** Call `export_deals_csv` to generate one on demand:
```
export_deals_csv() # latest report → report_dir/deals.csv
export_deals_csv(report_id: "20260422_…") # specific report
export_deals_csv(output_path: "/tmp/out.csv") # custom path
```
## Optimization Never Finishes
```bash
+147
View File
@@ -0,0 +1,147 @@
# VS Code MCP Integration Setup
## Quick Setup
### Option 1: Download Prebuilt Binary (Recommended)
```bash
# macOS (Apple Silicon)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
tar -xzf mt5.tar.gz
# Linux (x64)
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
tar -xzf mt5.tar.gz
```
### Option 2: Build from Source
```bash
cargo build --release
```
## Configure VS Code
### Method 1: Command Palette (Recommended)
1. Press `Cmd/Ctrl + Shift + P`
2. Run `MCP: Add Server`
3. Choose **Workspace** or **User** scope
4. Enter server name: `mt5-quant`
5. Enter command: `/path/to/mt5-quant/mcp-server/bin/mt5-quant`
### Method 2: Edit mcp.json Directly
Add to `.vscode/mcp.json` in your workspace:
```json
{
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
```
Create the file:
```bash
mkdir -p .vscode
cat > .vscode/mcp.json << 'EOF'
{
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
EOF
```
### Method 3: VS Code CLI
```bash
code --add-mcp '{"name":"mt5-quant","command":"/path/to/mt5-quant/mcp-server/bin/mt5-quant"}'
```
## Verify Setup
In Copilot chat, type:
```
Run verify_setup
```
Expected output:
```
Wine: /Applications/MetaTrader 5.app/.../wine64
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
Display: gui
Arch: arch -x86_64
```
## Configuration Locations
| Scope | Path | Use Case |
|-------|------|----------|
| Workspace | `.vscode/mcp.json` | Share with team via source control |
| User | `~/.vscode/mcp.json` | Personal tools across all projects |
| Dev Container | `devcontainer.json``customizations.vscode.mcp` | Containerized environments |
## Troubleshooting
### MCP server not appearing
1. Open **Output** panel (`Cmd/Ctrl + Shift + U`)
2. Select **MCP** from dropdown
3. Check for connection errors
4. Verify the path is absolute
### Config not found
The binary auto-detects its config, but you can also:
1. Run `setup.sh` to create `config/mt5-quant.yaml`
2. Or let the binary auto-discover on first run
### Dev Container Setup
Add to `.devcontainer/devcontainer.json`:
```json
{
"customizations": {
"vscode": {
"mcp": {
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
}
}
}
```
## Key Differences from Other IDEs
VS Code uses `servers` (not `mcpServers`) in the JSON structure:
```json
{
"servers": { // ← VS Code uses "servers"
"mt5-quant": {
"command": "..."
}
}
}
```
Other platforms use `mcpServers`.
## Resources
- [VS Code MCP Documentation](https://code.visualstudio.com/docs/copilot/customization/mcp-servers)
- [MCP Configuration Reference](https://code.visualstudio.com/docs/copilot/reference/mcp-configuration)
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
+35 -16
View File
@@ -6,12 +6,12 @@
```bash
# macOS (Apple Silicon)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-macos-arm64.tar.gz
tar -xzf mt5.tar.gz
# Linux (x64)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
curl -L -o mt5.tar.gz https://github.com/masdevid/mt5-quant/releases/latest/download/mcp-mt5-quant-linux-x64.tar.gz
tar -xzf mt5.tar.gz
```
### Option 2: Build from Source
@@ -22,14 +22,26 @@ bash scripts/build-rust.sh
### 2. Configure Windsurf
Edit `~/.windsurf/config.yaml`:
Edit `~/.codeium/windsurf/mcp_config.json`:
```yaml
mcpServers:
mt5-quant:
command: /Users/masdevid/jobs/mt5-quant/target/release/mt5-quant
env:
MT5_MCP_HOME: /Users/masdevid/jobs/mt5-quant
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant/mcp-server/bin/mt5-quant"
}
}
}
```
Or use the automated setup:
```bash
# Install binary to standard location
cp mcp-server/bin/mt5-quant ~/.local/bin/
# Then configure
bash scripts/setup.sh
```
### 3. Restart Windsurf
@@ -66,18 +78,25 @@ scp -r config/mt5-quant.yaml user@server:~/.config/mt5-quant/config/
- **NO Python required!**
### Windsurf Config on Target Machine
```yaml
mcpServers:
mt5-quant:
command: /usr/local/bin/mt5-quant
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/usr/local/bin/mt5-quant"
}
}
}
```
The binary auto-detects its config location. No environment variables needed.
## Troubleshooting
### MCP server not appearing
1. Check Windsurf logs: `~/.windsurf/logs/`
2. Verify executable path is absolute
3. Test executable manually: `./target/release/mt5-quant --help`
3. Test executable manually: `./mcp-server/bin/mt5-quant --help`
### Config not found
Set `MT5_MCP_HOME` environment variable or ensure config is at default location:
+30 -23
View File
@@ -1,8 +1,11 @@
#!/bin/bash
# Build release binaries for distribution
# Creates: dist/mt5-quant-{platform}.tar.gz
#!/usr/bin/env bash
# Build release binary and package it for distribution
# Creates: dist/mcp-mt5-quant-{platform}.tar.gz
#
# Usage:
# bash scripts/build-release.sh
set -e
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
@@ -10,16 +13,17 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "$PROJECT_ROOT"
VERSION=$(grep -E '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "=== Building MCP-MT5-Quant v${VERSION} ==="
echo "=== Building mt5-quant v${VERSION} ==="
echo ""
# Clean previous builds
rm -rf "$PROJECT_ROOT/dist"
mkdir -p "$PROJECT_ROOT/dist"
# Build current platform
echo "Building for current platform..."
# Build release binary
echo "Building release binary..."
RUSTFLAGS="-D warnings" cargo build --release
echo ""
# Detect platform
UNAME=$(uname -s)
@@ -30,43 +34,46 @@ if [[ "$UNAME" == "Darwin" ]]; then
elif [[ "$UNAME" == "Linux" ]]; then
PLATFORM="linux-${ARCH}"
else
PLATFORM="unknown"
PLATFORM="unknown-${ARCH}"
fi
PACKAGE_NAME="mcp-mt5-quant-${PLATFORM}"
PACKAGE_DIR="$PROJECT_ROOT/dist/${PACKAGE_NAME}"
echo "Packaging for ${PLATFORM}..."
mkdir -p "$PACKAGE_DIR"
mkdir -p "$PACKAGE_DIR/docs"
# Copy binary
# Binary
cp "$PROJECT_ROOT/target/release/mt5-quant" "$PACKAGE_DIR/"
chmod +x "$PACKAGE_DIR/mt5-quant"
# Copy config template
# Config template
mkdir -p "$PACKAGE_DIR/config"
cp "$PROJECT_ROOT/config/mt5-quant.example.yaml" "$PACKAGE_DIR/config/"
# Copy docs
cp "$PROJECT_ROOT/README.md" "$PACKAGE_DIR/"
cp "$PROJECT_ROOT/docs/WINDSURF.md" "$PACKAGE_DIR/WINDSURF_SETUP.md"
cp "$PROJECT_ROOT/CLAUDE.md" "$PACKAGE_DIR/"
# Documentation
cp "$PROJECT_ROOT/README.md" "$PACKAGE_DIR/"
cp "$PROJECT_ROOT/docs/QUICKSTART.md" "$PACKAGE_DIR/docs/"
cp "$PROJECT_ROOT/docs/CLAUDE.md" "$PACKAGE_DIR/docs/"
cp "$PROJECT_ROOT/docs/CURSOR.md" "$PACKAGE_DIR/docs/"
cp "$PROJECT_ROOT/docs/VSCODE.md" "$PACKAGE_DIR/docs/"
cp "$PROJECT_ROOT/docs/WINDSURF.md" "$PACKAGE_DIR/docs/"
# Create tarball
cd "$PROJECT_ROOT/dist"
tar -czf "${PACKAGE_NAME}.tar.gz" "$PACKAGE_NAME"
rm -rf "$PACKAGE_NAME"
echo ""
echo "=== Build Complete ==="
echo "=== Build complete ==="
echo ""
echo "Package: dist/${PACKAGE_NAME}.tar.gz"
echo "Binary: mt5-quant"
echo "Size: $(du -h "${PACKAGE_NAME}.tar.gz" | cut -f1)"
echo "Package : dist/${PACKAGE_NAME}.tar.gz"
echo "Size : $(du -h "${PACKAGE_NAME}.tar.gz" | cut -f1)"
echo ""
echo "Contents:"
tar -tzf "${PACKAGE_NAME}.tar.gz" | head -10
tar -tzf "${PACKAGE_NAME}.tar.gz"
echo ""
echo "To install:"
echo "Install:"
echo " tar -xzf ${PACKAGE_NAME}.tar.gz"
echo " cd ${PACKAGE_NAME}"
echo " sudo cp mt5-quant /usr/local/bin/"
echo " sudo cp ${PACKAGE_NAME}/mt5-quant /usr/local/bin/"
echo " mt5-quant --help"
+8 -15
View File
@@ -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 ""
+238
View File
@@ -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
View File
@@ -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 -5
View File
@@ -2,18 +2,18 @@
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.masdevid/mt5-quant",
"title": "MT5-Quant",
"description": "MCP server for MT5 strategy development. Compile, backtest, analyze MQL5 EAs on macOS/Linux.",
"description": "MT5 strategy development with 87 tools. Backtest, optimize, debug MQL5 EAs on macOS/Linux.",
"repository": {
"url": "https://github.com/masdevid/mt5-quant",
"source": "github"
},
"version": "1.29.0",
"version": "1.31.5",
"packages": [
{
"registryType": "mcpb",
"version": "1.29.0",
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.29.0/mcp-mt5-quant-macos-arm64.tar.gz",
"fileSha256": "5d56099e01c6df0c16bc8aa28fffab2e3ba7502f283c3c8273b70e4192230f01",
"version": "1.31.5",
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.31.5/mcp-mt5-quant-macos-arm64.tar.gz",
"fileSha256": "97e05d3bc97270866d5736660bd19f071fd0fabbc474ba481825d69ec2b4bdf5",
"transport": {
"type": "stdio"
},
+610 -1
View File
@@ -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,
}
}
}
+5 -19
View File
@@ -15,7 +15,7 @@ impl ReportExtractor {
pub fn extract(&self, report_path: &str, output_dir: &str) -> Result<ExtractionResult> {
let format = Self::detect_format(report_path);
let (metrics, deals) = match format {
ReportFormat::Xml => self.parse_xml(report_path)?,
ReportFormat::Html => self.parse_html(report_path)?,
@@ -24,22 +24,19 @@ impl ReportExtractor {
fs::create_dir_all(output_dir)?;
let metrics_path = Path::new(output_dir).join("metrics.json");
let deals_csv_path = Path::new(output_dir).join("deals.csv");
let deals_json_path = Path::new(output_dir).join("deals.json");
self.write_metrics(&metrics, &metrics_path)?;
self.write_deals_json(&deals, &deals_json_path)?;
self.write_deals_csv(&deals, &deals_csv_path)?;
Ok(ExtractionResult {
metrics,
deals,
metrics_path,
deals_csv_path,
deals_json_path,
})
}
pub fn write_deals_to_csv(&self, deals: &[Deal], path: &Path) -> Result<()> {
self.write_deals_csv(deals, path)
}
fn detect_format(path: &str) -> ReportFormat {
if path.ends_with(".xml") || path.ends_with(".htm.xml") {
return ReportFormat::Xml;
@@ -215,13 +212,6 @@ impl ReportExtractor {
Ok(())
}
fn write_deals_json(&self, deals: &[Deal], path: &Path) -> Result<()> {
let json = serde_json::to_string_pretty(deals)?;
let mut file = File::create(path)?;
file.write_all(json.as_bytes())?;
Ok(())
}
fn write_deals_csv(&self, deals: &[Deal], path: &Path) -> Result<()> {
let mut file = File::create(path)?;
writeln!(file, "time,deal,symbol,type,entry,volume,price,order,commission,swap,profit,balance,comment")?;
@@ -279,10 +269,6 @@ pub struct ExtractionResult {
pub deals: Vec<Deal>,
#[allow(dead_code)]
pub metrics_path: PathBuf,
#[allow(dead_code)]
pub deals_csv_path: PathBuf,
#[allow(dead_code)]
pub deals_json_path: PathBuf,
}
#[derive(Debug, Clone, Copy)]
+22 -8
View File
@@ -1,7 +1,8 @@
use anyhow::{anyhow, Result};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use tokio::time::timeout as tokio_timeout;
use crate::models::Config;
@@ -28,7 +29,11 @@ impl MqlCompiler {
Self { config }
}
pub fn compile(&self, source_path: &str) -> Result<CompileResult> {
pub async fn compile(&self, source_path: &str) -> Result<CompileResult> {
self.compile_with_timeout(source_path, Duration::from_secs(120)).await
}
pub async fn compile_with_timeout(&self, source_path: &str, timeout: Duration) -> Result<CompileResult> {
let source_path = Path::new(source_path);
if !source_path.exists() {
return Err(anyhow!("Source file not found: {}", source_path.display()));
@@ -61,7 +66,7 @@ impl MqlCompiler {
let staged_mq5 = &sync.dest_mq5;
tracing::info!("Staged {} file(s) to: {}", sync.files_copied, staged_mq5.display());
self.run_metaeditor(wine_exe, &wine_prefix, &metaeditor, staged_mq5)?;
self.run_metaeditor_with_timeout(wine_exe, &wine_prefix, &metaeditor, staged_mq5, timeout).await?;
// /log flag (no path) writes log adjacent to source: {ea_name}.log
let log_path = staged_mq5.with_extension("log");
@@ -197,15 +202,16 @@ impl MqlCompiler {
Ok(SyncStats { dest_mq5, files_copied })
}
/// Run MetaEditor to compile `source_mq5`.
/// Run MetaEditor to compile `source_mq5` with timeout.
/// Uses Unix host path for /compile: and bare /log flag (writes log adjacent to source).
/// Shell script intermediary required on macOS to preserve DYLD_* vars past SIP.
fn run_metaeditor(
async fn run_metaeditor_with_timeout(
&self,
wine_exe: &str,
wine_prefix: &Path,
metaeditor: &Path,
source_mq5: &Path,
timeout: Duration,
) -> Result<()> {
let mt5_dir = metaeditor.parent().unwrap_or(metaeditor);
@@ -242,16 +248,24 @@ impl MqlCompiler {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?;
}
Command::new("/bin/sh").arg(&script_path).output()?;
let compile_future = tokio::process::Command::new("/bin/sh")
.arg(&script_path)
.output();
let result = tokio_timeout(timeout, compile_future).await
.map_err(|_| anyhow!("Compilation timed out after {} seconds", timeout.as_secs()))?;
result?;
} else {
Command::new(wine_exe)
let compile_future = tokio::process::Command::new(wine_exe)
.arg(metaeditor)
.arg(format!("/compile:{}", source_mq5.display()))
.arg("/log")
.env("WINEPREFIX", wine_prefix)
.env("WINEDEBUG", "-all")
.current_dir(mt5_dir)
.output()?;
.output();
let result = tokio_timeout(timeout, compile_future).await
.map_err(|_| anyhow!("Compilation timed out after {} seconds", timeout.as_secs()))?;
result?;
}
Ok(())
}
+93
View File
@@ -12,10 +12,14 @@ use anyhow::Result;
use clap::Parser;
use serde_json::{json, Value};
use std::io::{stdout, Write};
use std::time::Instant;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::TcpListener;
use tracing::{info, error};
use crate::models::Config;
use crate::pipeline::backtest::{BacktestPipeline, BacktestParams};
#[derive(Parser)]
#[command(name = "mt5-quant")]
#[command(about = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP")]
@@ -27,6 +31,18 @@ struct Cli {
/// Run on TCP port for debugging
#[arg(short, long)]
port: Option<u16>,
/// Test backtest launch performance (direct Rust call, not MCP)
#[arg(long)]
test_launch: bool,
/// EA name for test launch
#[arg(long)]
ea: Option<String>,
/// Startup delay for test launch (default: 10)
#[arg(long)]
startup_delay: Option<u64>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
@@ -88,6 +104,11 @@ async fn main() -> Result<()> {
let cli = Cli::parse();
if cli.test_launch {
run_test_launch(cli.ea, cli.startup_delay).await?;
return Ok(());
}
if let Some(port) = cli.port {
run_tcp_server(port).await?;
} else {
@@ -97,10 +118,82 @@ async fn main() -> Result<()> {
Ok(())
}
async fn run_test_launch(ea: Option<String>, startup_delay: Option<u64>) -> Result<()> {
let expert = ea.ok_or_else(|| anyhow::anyhow!("--ea is required for test launch"))?;
let delay = startup_delay.unwrap_or(10);
println!("Testing MT5 backtest launch optimizations...");
println!("==============================================");
println!("EA: {}", expert);
println!("Startup delay: {}s", delay);
let config = Config::load()?;
let params = BacktestParams {
expert: expert.clone(),
symbol: "XAUUSD".to_string(),
from_date: "2024.01.01".to_string(),
to_date: "2024.01.31".to_string(),
timeframe: "M5".to_string(),
deposit: 10000,
model: 0,
leverage: 500,
set_file: None,
skip_compile: true,
skip_clean: false,
skip_analyze: true,
deep_analyze: false,
shutdown: false,
kill_existing: false,
timeout: 900,
gui: false,
startup_delay_secs: delay,
};
let pipeline = BacktestPipeline::new(config);
println!("\nLaunching backtest...");
let start = Instant::now();
match pipeline.launch_backtest(params).await {
Ok(job) => {
let elapsed = start.elapsed();
println!("✓ Launch completed in {:.2}s", elapsed.as_secs_f64());
println!(" Report ID: {}", job.report_id);
println!(" Report dir: {}", job.report_dir);
println!("\nUse get_backtest_status to monitor progress.");
}
Err(e) => {
let elapsed = start.elapsed();
println!("✗ Launch failed after {:.2}s: {}", elapsed.as_secs_f64(), e);
}
}
println!("\n==============================================");
println!("Test complete.");
Ok(())
}
async fn run_stdio_server() -> Result<()> {
info!("Starting MT5-Quant MCP server on stdio");
let server = std::sync::Arc::new(mcp_server::McpServer::new());
let (notification_tx, mut notification_rx) = tokio::sync::mpsc::unbounded_channel::<mcp_server::Notification>();
server.set_notification_sender(notification_tx).await;
// Spawn notification sender task
tokio::spawn(async move {
while let Some(notification) = notification_rx.recv().await {
let notification_json = json!({
"jsonrpc": "2.0",
"method": notification.method,
"params": notification.params,
});
println!("{}", notification_json);
let _ = stdout().flush();
}
});
let mut reader = BufReader::new(tokio::io::stdin());
let mut line = String::new();
+60 -11
View File
@@ -1,9 +1,11 @@
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::sync::{mpsc, Mutex};
use crate::{models::Config as ModelsConfig, tools::ToolHandler, McpError, McpRequest, McpResponse};
type NotificationCallback = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
/// Auto-verify result stored after first initialization
#[derive(Debug, Clone)]
#[allow(dead_code)]
@@ -13,11 +15,17 @@ struct AutoVerifyResult {
config_path: String,
}
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct Notification {
pub method: String,
pub params: Value,
}
pub struct McpServer {
initialized: Arc<Mutex<bool>>,
tool_handler: Arc<ToolHandler>,
tool_handler: Arc<Mutex<Option<ToolHandler>>>,
auto_verify_result: Arc<Mutex<Option<AutoVerifyResult>>>,
notification_tx: Arc<Mutex<Option<mpsc::UnboundedSender<Notification>>>>,
}
impl McpServer {
@@ -25,11 +33,37 @@ impl McpServer {
let config = ModelsConfig::load().unwrap_or_default();
Self {
initialized: Arc::new(Mutex::new(false)),
tool_handler: Arc::new(ToolHandler::new(config)),
tool_handler: Arc::new(Mutex::new(Some(ToolHandler::new(config)))),
auto_verify_result: Arc::new(Mutex::new(None)),
notification_tx: Arc::new(Mutex::new(None)),
}
}
pub async fn set_notification_sender(&self, tx: mpsc::UnboundedSender<Notification>) {
let mut guard = self.notification_tx.lock().await;
*guard = Some(tx.clone());
// Update tool handler with notification callback
let tx_clone = tx.clone();
let callback = Arc::new(move |method: &str, params: serde_json::Value| {
let _ = tx_clone.send(Notification {
method: method.to_string(),
params,
});
});
let config = ModelsConfig::load().unwrap_or_default();
let new_handler = ToolHandler::with_notification_callback(config, callback);
let mut handler_guard = self.tool_handler.lock().await;
*handler_guard = Some(new_handler);
}
pub async fn get_notification_sender(&self) -> Option<mpsc::UnboundedSender<Notification>> {
let guard = self.notification_tx.lock().await;
guard.clone()
}
/// Run verify_setup in background - non blocking
fn spawn_auto_verify(&self) {
let result_arc = self.auto_verify_result.clone();
@@ -247,12 +281,27 @@ impl McpServer {
}
async fn handle_tool_call(&self, tool_name: &str, arguments: &Value) -> Value {
self.tool_handler.handle(tool_name, arguments).await.unwrap_or_else(|e| json!({
"content": [{
"type": "text",
"text": format!("Tool execution failed: {}", e)
}],
"isError": true
}))
let handler_guard = self.tool_handler.lock().await;
let handler = handler_guard.as_ref().cloned();
drop(handler_guard);
match handler {
Some(h) => {
h.handle(tool_name, arguments).await.unwrap_or_else(|e| json!({
"content": [{
"type": "text",
"text": format!("Tool execution failed: {}", e)
}],
"isError": true
}))
}
None => json!({
"content": [{
"type": "text",
"text": "Tool handler not initialized"
}],
"isError": true
})
}
}
}
+4
View File
@@ -97,6 +97,7 @@ pub struct Config {
pub reports_dir: Option<String>,
pub backtest_login: Option<String>,
pub backtest_server: Option<String>,
pub backtest_password: Option<String>,
pub project_dir: Option<String>,
}
@@ -123,6 +124,7 @@ impl Default for Config {
reports_dir: None,
backtest_login: None,
backtest_server: None,
backtest_password: None,
project_dir: None,
}
}
@@ -404,6 +406,7 @@ impl Config {
reports_dir: map.get("reports_dir").cloned(),
backtest_login: map.get("backtest_login").cloned(),
backtest_server: map.get("backtest_server").cloned(),
backtest_password: map.get("backtest_password").cloned(),
project_dir: map.get("project_dir").cloned(),
})
}
@@ -430,6 +433,7 @@ impl Config {
"reports_dir" => self.reports_dir.clone().unwrap_or_else(|| "reports".to_string()),
"backtest_login" => self.backtest_login.clone().unwrap_or_default(),
"backtest_server" => self.backtest_server.clone().unwrap_or_default(),
"backtest_password" => self.backtest_password.clone().unwrap_or_default(),
"project_dir" => self.project_dir.clone().unwrap_or_default(),
_ => String::new(),
}
+47
View File
@@ -1,6 +1,9 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
// Re-export chrono for BacktestJob
use chrono;
#[allow(dead_code)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Report {
@@ -31,6 +34,8 @@ pub struct PipelineMetadata {
pub report_dir: String,
pub duration_seconds: i64,
pub files: FilePaths,
#[serde(default)]
pub no_trades: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -48,6 +53,9 @@ pub struct BacktestStatus {
pub elapsed_seconds: i64,
pub is_complete: bool,
pub message: String,
pub report_dir: Option<String>,
pub mt5_running: Option<bool>,
pub report_found: Option<bool>,
}
#[allow(dead_code)]
@@ -60,4 +68,43 @@ pub enum PipelineStage {
Extract,
Analyze,
Done,
Failed,
}
/// Track a running backtest job for fire-and-poll pattern
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BacktestJob {
pub report_id: String,
pub report_dir: String,
pub expert: String,
pub symbol: String,
pub timeframe: String,
pub launched_at: String,
pub mt5_pid: Option<u32>,
pub expected_report_path: String,
pub timeout_seconds: u64,
}
impl BacktestJob {
pub fn new(
report_id: String,
report_dir: String,
expert: String,
symbol: String,
timeframe: String,
expected_report_path: String,
timeout_seconds: u64,
) -> Self {
Self {
report_id,
report_dir,
expert,
symbol,
timeframe,
launched_at: chrono::Utc::now().to_rfc3339(),
mt5_pid: None,
expected_report_path,
timeout_seconds,
}
}
}
+351 -81
View File
@@ -1,21 +1,26 @@
use anyhow::{anyhow, Result};
use chrono;
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use tokio::time::{sleep, Duration};
use crate::analytics::{DealAnalyzer, ReportExtractor};
use crate::compile::MqlCompiler;
use crate::models::config::Config;
use crate::models::report::{PipelineMetadata, FilePaths};
use crate::models::report::{PipelineMetadata, FilePaths, BacktestJob};
use crate::storage::{ReportDb, ReportEntry};
type NotificationCallback = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
pub struct BacktestPipeline {
config: Config,
compiler: MqlCompiler,
extractor: ReportExtractor,
analyzer: DealAnalyzer,
notification_callback: Option<NotificationCallback>,
}
pub struct BacktestParams {
@@ -38,6 +43,7 @@ pub struct BacktestParams {
pub kill_existing: bool,
pub timeout: u64,
pub gui: bool,
pub startup_delay_secs: u64,
}
pub struct PipelineResult {
@@ -58,6 +64,21 @@ impl BacktestPipeline {
compiler,
extractor,
analyzer,
notification_callback: None,
}
}
pub fn with_notification_callback(config: Config, callback: NotificationCallback) -> Self {
let compiler = MqlCompiler::new(config.clone());
let extractor = ReportExtractor::new();
let analyzer = DealAnalyzer::new();
Self {
config,
compiler,
extractor,
analyzer,
notification_callback: Some(callback),
}
}
@@ -73,7 +94,7 @@ impl BacktestPipeline {
if !params.skip_compile {
self.log_progress(&progress_log, "COMPILE").await;
self.compile_ea(&params.expert).await?;
self.compile_ea(&params.expert, params.timeout).await?;
}
if !params.skip_clean {
@@ -90,6 +111,13 @@ impl BacktestPipeline {
&report_dir.to_string_lossy(),
)?;
// Handle case where EA didn't trade - no deals generated
if extraction.deals.is_empty() {
tracing::warn!("Backtest completed but no deals were generated - EA did not trade during this period");
let warning_path = report_dir.join("NO_TRADES_WARNING.txt");
let _ = fs::write(&warning_path, "Warning: No deals were generated during this backtest.\nThe EA did not execute any trades during the specified date range.\n");
}
// Move equity chart images to OS temp dir, then delete the HTML report.
let charts_dir = self.relocate_charts(&report_path, &report_id).await;
let _ = fs::remove_file(&report_path);
@@ -108,10 +136,10 @@ impl BacktestPipeline {
self.log_progress(&progress_log, "DONE").await;
let duration = (chrono::Utc::now() - start_time).num_seconds();
self.save_metadata(&params, &report_dir, duration).await?;
self.save_metadata(&params, &report_dir, duration, extraction.deals.is_empty()).await?;
// Register in the SQLite report registry.
self.register_in_db(
// Register in the SQLite report registry and store deals.
let db = self.register_in_db(
&report_id,
&params,
&report_dir,
@@ -122,14 +150,224 @@ impl BacktestPipeline {
)
.await;
if let Some(db) = db {
if let Err(e) = db.insert_deals(&report_id, &extraction.deals) {
tracing::warn!("Failed to store deals in DB: {}", e);
}
}
let message = if extraction.deals.is_empty() {
"Backtest completed successfully, but EA did not execute any trades during this period".to_string()
} else {
"Backtest completed successfully".to_string()
};
Ok(PipelineResult {
success: true,
report_dir,
duration_seconds: duration,
message: "Backtest completed successfully".to_string(),
message,
})
}
/// Launch backtest in fire-and-forget mode: compile, clean, launch MT5, return immediately.
/// Returns a BacktestJob that can be used with get_backtest_status to poll for completion.
pub async fn launch_backtest(&self, params: BacktestParams) -> Result<BacktestJob> {
let _start_time = chrono::Utc::now();
let report_id = self.generate_report_id(&params);
let report_dir = self.config.reports_dir().join(&report_id);
fs::create_dir_all(&report_dir)?;
let progress_log = report_dir.join("progress.log");
self.log_progress(&progress_log, "START").await;
if !params.skip_compile {
self.log_progress(&progress_log, "COMPILE").await;
self.compile_ea(&params.expert, params.timeout).await?;
}
if !params.skip_clean {
self.log_progress(&progress_log, "CLEAN").await;
self.clean_cache(&params.expert).await?;
}
self.log_progress(&progress_log, "BACKTEST").await;
// Get MT5 paths
let mt5_dir = self.config.mt5_dir()
.ok_or_else(|| anyhow!("MT5 directory not configured"))?;
let wine_exe = self.config.wine_executable.as_ref()
.ok_or_else(|| anyhow!("wine_executable not configured"))?;
let wine_prefix = mt5_dir
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent())
.map(|p| p.to_path_buf())
.ok_or_else(|| anyhow!("Could not determine Wine prefix from terminal_dir"))?;
let reports_dir = mt5_dir.join("reports");
fs::create_dir_all(&reports_dir)?;
// Write params via /config: (triggers tester) and terminal.ini (redundancy).
let ini_content = self.build_backtest_ini(&params, &report_id)?;
let config_host = wine_prefix.join("drive_c").join("backtest_config.ini");
fs::write(&config_host, ini_content.as_bytes())?;
self.update_terminal_ini(&params, &report_id)?;
// Kill any running MT5
self.kill_mt5().await?;
// Launch MT5 (fire and forget)
let mut cmd = self.build_wine_launch(wine_exe, &wine_prefix)?;
let child = cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()?;
let pid = child.id();
tracing::info!("MT5 launched with PID {:?} for backtest {}", pid, report_id);
// Create and save the job tracking file
let expected_report = reports_dir.join(format!("{}.htm", report_id));
let job = BacktestJob::new(
report_id.clone(),
report_dir.to_string_lossy().to_string(),
params.expert.clone(),
params.symbol.clone(),
params.timeframe.clone(),
expected_report.to_string_lossy().to_string(),
params.timeout,
);
// Save job info for polling
let job_path = report_dir.join("job.json");
fs::write(&job_path, serde_json::to_string_pretty(&job)?)?;
// Save initial metadata
self.save_metadata(&params, &report_dir, 0, false).await?;
// Register in DB as "running"
let db = ReportDb::new(&Config::db_path());
if let Err(e) = db.init() {
tracing::warn!("Failed to init report DB: {}", e);
}
// Spawn background task to monitor completion and update status
let report_dir_clone = report_dir.clone();
let expected_report_clone = expected_report.clone();
let timeout_secs = params.timeout;
let report_id_clone = report_id.clone();
let notification_callback = self.notification_callback.clone();
tokio::spawn(async move {
Self::monitor_backtest_completion(
report_dir_clone,
expected_report_clone,
timeout_secs,
report_id_clone,
notification_callback,
).await;
});
Ok(job)
}
/// Background task to monitor backtest completion and update status file.
async fn monitor_backtest_completion(
report_dir: PathBuf,
expected_report: PathBuf,
timeout_secs: u64,
report_id: String,
notification_callback: Option<NotificationCallback>,
) {
let start = tokio::time::Instant::now();
let deadline = start + Duration::from_secs(timeout_secs);
let grace_period = Duration::from_secs(30);
let poll_start = std::time::SystemTime::now();
loop {
let _elapsed = start.elapsed().as_secs();
// Check for report file
for ext in &[".htm", ".htm.xml", ".html"] {
let candidate = expected_report.with_extension(ext.trim_start_matches('.'));
if candidate.exists() {
tracing::info!("Backtest {} completed: report found at {}", report_id, candidate.display());
Self::update_job_status(&report_dir, "completed", Some(candidate.to_string_lossy().to_string())).await;
if let Some(ref callback) = notification_callback {
callback("backtest_completed", json!({
"report_id": report_id,
"report_path": candidate.to_string_lossy().to_string(),
"status": "completed"
}));
}
return;
}
}
// Check process liveness after grace period
let in_grace = start.elapsed() <= grace_period;
let mt5_alive = Self::is_mt5_running();
if !in_grace && !mt5_alive {
// MT5 exited without report - check for any newer report
if let Some(path) = Self::find_newest_report(expected_report.parent().unwrap(), poll_start) {
tracing::info!("Backtest {} completed: found fallback report {}", report_id, path.display());
Self::update_job_status(&report_dir, "completed", Some(path.to_string_lossy().to_string())).await;
if let Some(ref callback) = notification_callback {
callback("backtest_completed", json!({
"report_id": report_id,
"report_path": path.to_string_lossy().to_string(),
"status": "completed"
}));
}
} else {
tracing::warn!("Backtest {} failed: MT5 exited without producing a report", report_id);
Self::update_job_status(&report_dir, "failed", None).await;
if let Some(ref callback) = notification_callback {
callback("backtest_failed", json!({
"report_id": report_id,
"status": "failed",
"reason": "MT5 exited without producing a report"
}));
}
}
return;
}
if tokio::time::Instant::now() > deadline {
tracing::warn!("Backtest {} timed out after {} seconds", report_id, timeout_secs);
Self::update_job_status(&report_dir, "timeout", None).await;
if let Some(ref callback) = notification_callback {
callback("backtest_timeout", json!({
"report_id": report_id,
"status": "timeout",
"timeout_seconds": timeout_secs
}));
}
return;
}
sleep(Duration::from_secs(2)).await;
}
}
/// Update job status in job.json file.
async fn update_job_status(report_dir: &Path, status: &str, report_path: Option<String>) {
let job_path = report_dir.join("job.json");
if let Ok(job_json) = fs::read_to_string(&job_path) {
if let Ok(mut job) = serde_json::from_str::<serde_json::Value>(&job_json) {
job["status"] = serde_json::Value::String(status.to_string());
job["completed_at"] = serde_json::Value::String(chrono::Utc::now().to_rfc3339());
if let Some(path) = report_path {
job["actual_report_path"] = serde_json::Value::String(path);
}
if let Ok(updated) = serde_json::to_string_pretty(&job) {
let _ = fs::write(&job_path, updated);
}
}
}
}
/// Move equity chart images (*.png, *.gif) from MT5's reports dir to OS temp,
/// returning the temp path if any images were found.
async fn relocate_charts(&self, html_path: &Path, report_id: &str) -> Option<PathBuf> {
@@ -191,11 +429,11 @@ impl BacktestPipeline {
set_snapshot: Option<&Path>,
metrics: &crate::models::metrics::Metrics,
duration: i64,
) {
) -> Option<ReportDb> {
let db = ReportDb::new(&Config::db_path());
if let Err(e) = db.init() {
tracing::warn!("Failed to init report DB: {}", e);
return;
return None;
}
let entry = ReportEntry {
@@ -229,10 +467,13 @@ impl BacktestPipeline {
if let Err(e) = db.insert(&entry) {
tracing::warn!("Failed to register report in DB: {}", e);
return None;
}
Some(db)
}
async fn compile_ea(&self, expert: &str) -> Result<()> {
async fn compile_ea(&self, expert: &str, timeout_secs: u64) -> Result<()> {
let mut search_paths = vec![
PathBuf::from(&self.config.get("project_dir")).join("src/experts").join(format!("{}.mq5", expert)),
PathBuf::from(&self.config.get("project_dir")).join("src").join(format!("{}.mq5", expert)),
@@ -252,7 +493,8 @@ impl BacktestPipeline {
.find(|p| p.exists())
.ok_or_else(|| anyhow!("Cannot find {}.mq5 — searched project_dir and MT5 Experts dir", expert))?;
let result = self.compiler.compile(&source_path.to_string_lossy())?;
let timeout = std::time::Duration::from_secs(timeout_secs.min(300)); // Max 5 min for compile
let result = self.compiler.compile_with_timeout(&source_path.to_string_lossy(), timeout).await?;
if !result.success {
return Err(anyhow!(
@@ -352,12 +594,8 @@ impl BacktestPipeline {
let reports_dir = mt5_dir.join("reports");
fs::create_dir_all(&reports_dir)?;
// Write backtest_config.ini (used by wine64 shell-script launch via /config:)
// and also patch terminal.ini so the Strategy Tester panel shows the right
// settings if the user opens MT5 manually.
// Write params via /config: (triggers tester auto-start) and terminal.ini (redundancy).
let ini_content = self.build_backtest_ini(params, report_id)?;
// Write to drive_c root (C:\backtest_config.ini) — no spaces in path avoids
// Wine argument-quoting issues when MT5 parses the /config: value.
let config_host = wine_prefix.join("drive_c").join("backtest_config.ini");
fs::write(&config_host, ini_content.as_bytes())?;
self.update_terminal_ini(params, report_id)?;
@@ -378,8 +616,10 @@ impl BacktestPipeline {
.spawn()?;
// Give MT5 time to fully initialize before polling.
// MT5 app startup (Wine init + network auth + tester) typically takes 1520 s.
sleep(Duration::from_secs(20)).await;
// MT5 app startup (Wine init + network auth + tester) typically takes 1015 s.
// Configurable via startup_delay_secs parameter (default 10s for faster launches).
let delay = if params.startup_delay_secs > 0 { params.startup_delay_secs } else { 10 };
sleep(Duration::from_secs(delay)).await;
// Poll for the report file (MT5 writes it when the backtest completes).
// Grace period: don't check process liveness for the first 30 s after launch —
@@ -424,15 +664,21 @@ impl BacktestPipeline {
}
}
/// Write backtest parameters into terminal.ini [Tester] section so MT5 reads
/// them on startup without needing a /config: command-line argument.
/// Write backtest params into terminal.ini [Tester] section.
/// MT5 uses this when restarting — it reconnects via the saved session in common.ini
/// rather than requiring fresh credentials. This is more reliable than /config: alone,
/// which requires a password for fresh authentication.
fn update_terminal_ini(&self, params: &BacktestParams, report_id: &str) -> Result<()> {
let mt5_dir = self.config.mt5_dir()
.ok_or_else(|| anyhow!("MT5 directory not configured"))?;
let terminal_ini = mt5_dir.join("config").join("terminal.ini");
// Portable mode uses config/ inside the install dir; non-portable uses the root.
let terminal_ini = if mt5_dir.join("config").exists() {
mt5_dir.join("config").join("terminal.ini")
} else {
mt5_dir.join("terminal.ini")
};
let raw = fs::read(&terminal_ini)
.unwrap_or_default();
let raw = fs::read(&terminal_ini).unwrap_or_default();
let text = if raw.starts_with(&[0xFF, 0xFE]) {
raw[2..].chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
@@ -445,21 +691,31 @@ impl BacktestPipeline {
};
let period = match params.timeframe.as_str() {
"M1" => 1u32, "M5" => 5, "M15" => 15, "M30" => 30,
"H1" => 60, "H4" => 240, "D1" => 1440,
_ => 5,
"M1" => 1u32, "M5" => 5, "M15" => 15, "M30" => 30,
"H1" => 60, "H4" => 240, "D1" => 1440,
_ => 5,
};
let from_ts = Self::date_str_to_unix(&params.from_date)?;
let to_ts = Self::date_str_to_unix(&params.to_date)?;
let currency = self.config.backtest_currency.as_deref().unwrap_or("USD");
let expert_path = if let Some(experts_dir) = &self.config.experts_dir {
let nested = std::path::Path::new(experts_dir).join(&params.expert).join(format!("{}.mq5", params.expert));
if nested.exists() {
format!("Experts\\{}\\{}.ex5", params.expert, params.expert)
} else {
format!("Experts\\{}.ex5", params.expert)
}
} else {
format!("Experts\\{}.ex5", params.expert)
};
let set_file_line = params.set_file.as_ref()
.map(|p| format!("ExpertParameters={}\n", p))
.unwrap_or_default();
let updates: &[(&str, String)] = &[
("Expert", self.resolve_expert_path(&params.expert)),
("Expert", expert_path),
("Symbol", params.symbol.clone()),
("Period", period.to_string()),
("DateRange", "3".into()),
@@ -478,31 +734,15 @@ impl BacktestPipeline {
("ShutdownTerminal", if params.shutdown { "1" } else { "0" }.into()),
];
let updated = Self::patch_ini_section(&text, "Tester", updates)
+ &set_file_line;
let updated = Self::patch_ini_section(&text, "Tester", updates) + &set_file_line;
let bom_utf16: Vec<u8> = [0xFF, 0xFE].iter().copied()
.chain(updated.encode_utf16().flat_map(|c| c.to_le_bytes()))
.collect();
fs::write(&terminal_ini, bom_utf16)?;
tracing::info!("terminal.ini [Tester] updated for backtest {}", report_id);
tracing::info!("terminal.ini [Tester] updated {}", terminal_ini.display());
Ok(())
}
/// Parse "YYYY.MM.DD" and return a Unix timestamp (seconds since 1970-01-01 UTC).
fn date_str_to_unix(date: &str) -> Result<i64> {
let parts: Vec<u32> = date.split('.').filter_map(|p| p.parse().ok()).collect();
if parts.len() != 3 {
return Err(anyhow!("Invalid date format: {}", date));
}
let dt = chrono::NaiveDate::from_ymd_opt(parts[0] as i32, parts[1], parts[2])
.ok_or_else(|| anyhow!("Invalid date: {}", date))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow!("Date conversion failed"))?;
Ok(chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(dt, chrono::Utc).timestamp())
}
/// Replace or add key=value pairs in a named INI section.
fn patch_ini_section(text: &str, section: &str, updates: &[(&str, String)]) -> String {
let section_header = format!("[{}]", section);
let mut result = String::with_capacity(text.len() + 256);
@@ -519,7 +759,6 @@ impl BacktestPipeline {
continue;
}
if trimmed.starts_with('[') && in_section {
// End of our section — flush any keys not yet written
for (k, v) in &pending {
result.push_str(&format!("{}={}\n", k, v));
}
@@ -538,7 +777,6 @@ impl BacktestPipeline {
result.push_str(line);
result.push('\n');
}
// Flush remaining keys if section was at end of file
if in_section {
for (k, v) in &pending {
result.push_str(&format!("{}={}\n", k, v));
@@ -547,19 +785,33 @@ impl BacktestPipeline {
result
}
fn date_str_to_unix(date: &str) -> Result<i64> {
let parts: Vec<u32> = date.split('.').filter_map(|p| p.parse().ok()).collect();
if parts.len() != 3 {
return Err(anyhow!("Invalid date format: {}", date));
}
let dt = chrono::NaiveDate::from_ymd_opt(parts[0] as i32, parts[1], parts[2])
.ok_or_else(|| anyhow!("Invalid date: {}", date))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow!("Date conversion failed"))?;
Ok(chrono::DateTime::<chrono::Utc>::from_naive_utc_and_offset(dt, chrono::Utc).timestamp())
}
/// Build the OS-appropriate command to launch MT5 with the backtest config.
///
/// - macOS MT5.app bundle: use `open -a "MetaTrader 5" --args /config:...`
/// The native launcher handles Wine env setup (DYLD vars are stripped by SIP
/// when set on child processes spawned from a Rust binary).
/// - macOS MT5.app bundle: use shell script to bypass SIP and set DYLD vars.
/// Relies on terminal.ini for backtest config (more reliable than /config:).
/// - macOS CrossOver / Linux Wine: standard WINEPREFIX + wine64 direct invocation.
/// Also relies on terminal.ini for backtest config.
fn build_wine_launch(&self, wine_exe: &str, wine_prefix: &Path) -> Result<Command> {
if wine_exe.contains("MetaTrader 5.app") {
// macOS MT5.app — the Swift launcher ignores --args so we can't pass
// /config: via `open`. Instead, write a temp shell script that sets
// DYLD_FALLBACK_LIBRARY_PATH and invokes wine64 with /config: directly.
// DYLD_FALLBACK_LIBRARY_PATH and invokes wine64 directly.
// Shell scripts bypass the SIP restriction that strips DYLD_* vars
// when Rust spawns a codesigned binary as a direct child process.
// NOTE: We rely on terminal.ini for config instead of /config: because
// MT5.app's bundled wine64 doesn't reliably handle /config: arguments.
let wine_bin = Path::new(wine_exe);
let wine_root = wine_bin
.parent() // bin/
@@ -572,11 +824,13 @@ impl BacktestPipeline {
let dyld = format!("{}:{}:/usr/lib:/usr/local/lib",
ext_libs.display(), wine_libs.display());
// Use host path for the exe; config at drive root to avoid spaces in path.
// Use host path for the exe; use /config: with backslash-escaped path
let terminal_host = wine_prefix.join("drive_c")
.join("Program Files").join("MetaTrader 5").join("terminal64.exe");
let config_win = r"C:\backtest_config.ini";
// /config: triggers the Strategy Tester to auto-start.
// terminal.ini is also patched with the same params as a belt-and-suspenders.
let config_win = r"C:\backtest_config.ini";
let script = format!(
"#!/bin/sh\n\
export DYLD_FALLBACK_LIBRARY_PATH='{dyld}'\n\
@@ -592,43 +846,41 @@ impl BacktestPipeline {
);
let script_path = std::env::temp_dir().join("mt5_backtest_launch.sh");
fs::write(&script_path, &script)?;
// chmod +x
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path,
fs::Permissions::from_mode(0o755))?;
// Only rewrite script if it doesn't exist or content differs (optimization)
let needs_write = !script_path.exists() || fs::read_to_string(&script_path).map(|existing| existing != script).unwrap_or(true);
if needs_write {
fs::write(&script_path, &script)?;
// chmod +x
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&script_path,
fs::Permissions::from_mode(0o755))?;
}
tracing::debug!("Created/updated launch script: {}", script_path.display());
} else {
tracing::debug!("Reusing existing launch script: {}", script_path.display());
}
tracing::info!("Launching MT5 via shell script: {}", script_path.display());
tracing::info!("Launching MT5 via shell script (terminal.ini mode): {}", script_path.display());
let mut cmd = Command::new("/bin/sh");
cmd.arg(&script_path);
return Ok(cmd);
}
// CrossOver / Linux: invoke wine64 directly with WINEPREFIX set.
// Params are already written to terminal.ini so no /config: arg needed.
// CrossOver / Linux: invoke wine64 directly with /config: to trigger the tester.
let terminal_win_path = r"C:\Program Files\MetaTrader 5\terminal64.exe";
let config_win = r"C:\backtest_config.ini";
let mut cmd = Command::new(wine_exe);
cmd.arg(terminal_win_path)
.arg(format!("/config:{}", config_win))
.env("WINEPREFIX", wine_prefix)
.env("WINEDEBUG", "-all");
Ok(cmd)
}
/// For terminal.ini: path relative to MQL5/ (e.g. `Experts\DPS21\DPS21.ex5`).
fn resolve_expert_path(&self, expert: &str) -> String {
if let Some(experts_dir) = &self.config.experts_dir {
let nested_ex5 = PathBuf::from(experts_dir).join(expert).join(format!("{}.ex5", expert));
let nested_mq5 = PathBuf::from(experts_dir).join(expert).join(format!("{}.mq5", expert));
if nested_ex5.exists() || nested_mq5.exists() {
return format!("Experts\\{}\\{}.ex5", expert, expert);
}
}
format!("Experts\\{}.ex5", expert)
}
/// For /config: INI: path relative to MQL5/Experts/ (e.g. `DPS21\DPS21.ex5`).
/// The /config: format does NOT include the "Experts\" prefix.
fn resolve_backtest_ini_expert_path(&self, expert: &str) -> String {
@@ -645,11 +897,17 @@ impl BacktestPipeline {
fn build_backtest_ini(&self, params: &BacktestParams, report_id: &str) -> Result<String> {
let mut ini = String::new();
// [Common] section: only written when explicit credentials are configured.
// Without it, MT5 reuses its saved session via common.ini (no password needed).
if let Some(login) = &self.config.backtest_login {
if let Some(server) = &self.config.backtest_server {
ini.push_str("[Common]\n");
ini.push_str(&format!("Login={}\n", login));
ini.push_str(&format!("Server={}\n\n", server));
ini.push_str(&format!("Server={}\n", server));
if let Some(password) = &self.config.backtest_password {
ini.push_str(&format!("Password={}\n", password));
}
ini.push_str("\n");
}
}
@@ -697,11 +955,22 @@ impl BacktestPipeline {
tracing::info!("Stopping existing MT5 instance...");
// SIGKILL immediately — MT5 holds no state we care about preserving.
let mut killed_any = false;
for pat in &patterns {
let _ = Command::new("pkill").args(["-KILL", "-f", pat.as_str()]).output();
let result = Command::new("pkill").args(["-KILL", "-f", pat.as_str()]).output();
if result.map(|o| o.status.success()).unwrap_or(false) {
killed_any = true;
}
}
let wineserver_result = Command::new("pkill").args(["-KILL", "-f", "wineserver"]).output();
if wineserver_result.map(|o| o.status.success()).unwrap_or(false) {
killed_any = true;
}
// Only sleep if we actually killed something - skip if no processes were running
if killed_any {
sleep(Duration::from_secs(2)).await; // Reduced from 3s to 2s
}
let _ = Command::new("pkill").args(["-KILL", "-f", "wineserver"]).output();
sleep(Duration::from_secs(3)).await;
Ok(())
}
@@ -761,7 +1030,7 @@ impl BacktestPipeline {
let _ = fs::write(log_path, line);
}
async fn save_metadata(&self, params: &BacktestParams, report_dir: &Path, duration: i64) -> Result<()> {
async fn save_metadata(&self, params: &BacktestParams, report_dir: &Path, duration: i64, no_trades: bool) -> Result<()> {
let metadata = PipelineMetadata {
expert: params.expert.clone(),
symbol: params.symbol.clone(),
@@ -781,6 +1050,7 @@ impl BacktestPipeline {
deals_csv: report_dir.join("deals.csv").to_string_lossy().to_string(),
deals_json: report_dir.join("deals.json").to_string_lossy().to_string(),
},
no_trades,
};
let json = serde_json::to_string_pretty(&metadata)?;
+674
View File
@@ -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,594 @@ 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 mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE 1=1"
.to_string();
// Build OR conditions for tags - use JSON1 extension for tag matching
if !tags.is_empty() {
let tag_conditions: Vec<String> = tags.iter()
.map(|tag| format!("tags LIKE '%{}%'", tag.replace("'", "''")))
.collect();
sql.push_str(&format!(" AND ({})", tag_conditions.join(" OR ")));
}
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
let mut stmt = conn.prepare(&sql)?;
let entries: Vec<ReportEntry> = stmt
.query_map([], |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Search reports by notes text (case-insensitive LIKE)
pub fn search_by_notes(&self, query: &str, limit: usize) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let pattern = format!("%{}%", query.replace("'", "''"));
let mut stmt = conn.prepare(
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE notes LIKE '{}' ORDER BY created_at DESC LIMIT {}",
pattern, limit)
)?;
let entries: Vec<ReportEntry> = stmt
.query_map([], |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Find reports by set file (original or snapshot)
pub fn search_by_set_file(&self, set_file: &str, limit: usize) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let pattern = format!("%{}%", set_file.replace("'", "''"));
let mut stmt = conn.prepare(
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE (set_file_original LIKE '{}' OR set_snapshot_path LIKE '{}') \
ORDER BY created_at DESC LIMIT {}",
pattern, pattern, limit)
)?;
let entries: Vec<ReportEntry> = stmt
.query_map([], |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Search by backtest date range (from_date and to_date fields)
pub fn search_by_date_range(
&self,
from_start: Option<&str>,
from_end: Option<&str>,
to_start: Option<&str>,
to_end: Option<&str>,
limit: usize,
) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE 1=1"
.to_string();
let mut params: Vec<String> = Vec::new();
if let Some(start) = from_start {
sql.push_str(" AND from_date >= ?");
params.push(start.to_string());
}
if let Some(end) = from_end {
sql.push_str(" AND from_date <= ?");
params.push(end.to_string());
}
if let Some(start) = to_start {
sql.push_str(" AND to_date >= ?");
params.push(start.to_string());
}
if let Some(end) = to_end {
sql.push_str(" AND to_date <= ?");
params.push(end.to_string());
}
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
let mut stmt = conn.prepare(&sql)?;
let entries: Vec<ReportEntry> = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Get comparable reports (same expert/symbol/timeframe for comparison)
pub fn get_comparable(
&self,
expert: &str,
symbol: &str,
timeframe: &str,
exclude_id: Option<&str>,
limit: usize,
) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE expert = ? AND symbol = ? AND timeframe = ?"
.to_string();
let mut params: Vec<String> = vec![
expert.to_string(),
symbol.to_string(),
timeframe.to_string(),
];
if let Some(id) = exclude_id {
sql.push_str(" AND id != ?");
params.push(id.to_string());
}
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
let mut stmt = conn.prepare(&sql)?;
let entries: Vec<ReportEntry> = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Get reports sorted by a specific metric (for best/worst queries)
pub fn get_sorted_by(
&self,
sort_column: &str,
ascending: bool,
limit: usize,
filters: &ReportFilters,
) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
// Validate sort column to prevent SQL injection
let valid_columns = ["net_profit", "profit_factor", "max_dd_pct", "win_rate_pct",
"sharpe_ratio", "recovery_factor", "total_trades", "created_at"];
if !valid_columns.contains(&sort_column) {
return Err(anyhow::anyhow!("Invalid sort column: {}", sort_column));
}
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE 1=1"
.to_string();
let mut filter_params: Vec<String> = Vec::new();
if let Some(ea) = &filters.expert {
sql.push_str(" AND expert LIKE ?");
filter_params.push(format!("%{}%", ea));
}
if let Some(sym) = &filters.symbol {
sql.push_str(" AND symbol = ?");
filter_params.push(sym.clone());
}
if let Some(tf) = &filters.timeframe {
sql.push_str(" AND timeframe = ?");
filter_params.push(tf.clone());
}
if let Some(verdict) = &filters.verdict {
sql.push_str(" AND verdict = ?");
filter_params.push(verdict.clone());
}
// For non-created_at columns, only include rows where that column is not NULL
if sort_column != "created_at" {
sql.push_str(&format!(" AND {} IS NOT NULL", sort_column));
}
let order = if ascending { "ASC" } else { "DESC" };
sql.push_str(&format!(" ORDER BY {} {} LIMIT {}", sort_column, order, limit));
let mut stmt = conn.prepare(&sql)?;
let entries: Vec<ReportEntry> = stmt
.query_map(rusqlite::params_from_iter(filter_params.iter()), |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Get aggregate statistics for reports
pub fn get_stats(&self, filters: &ReportFilters) -> Result<ReportStats> {
let conn = self.connect()?;
let mut sql = "SELECT \
COUNT(*), \
AVG(net_profit), \
AVG(profit_factor), \
AVG(max_dd_pct), \
AVG(win_rate_pct), \
AVG(sharpe_ratio), \
SUM(CASE WHEN net_profit > 0 THEN 1 ELSE 0 END), \
SUM(CASE WHEN verdict = 'pass' THEN 1 ELSE 0 END), \
SUM(CASE WHEN verdict = 'fail' THEN 1 ELSE 0 END), \
SUM(CASE WHEN verdict = 'marginal' THEN 1 ELSE 0 END) \
FROM reports WHERE 1=1"
.to_string();
let mut filter_params: Vec<String> = Vec::new();
if let Some(ea) = &filters.expert {
sql.push_str(" AND expert LIKE ?");
filter_params.push(format!("%{}%", ea));
}
if let Some(sym) = &filters.symbol {
sql.push_str(" AND symbol = ?");
filter_params.push(sym.clone());
}
if let Some(tf) = &filters.timeframe {
sql.push_str(" AND timeframe = ?");
filter_params.push(tf.clone());
}
if let Some(verdict) = &filters.verdict {
sql.push_str(" AND verdict = ?");
filter_params.push(verdict.clone());
}
let stats: ReportStats = conn.query_row(
&sql,
rusqlite::params_from_iter(filter_params.iter()),
|row| {
let total: i64 = row.get(0)?;
let profitable: Option<i64> = row.get(6)?;
let pass_count: Option<i64> = row.get(7)?;
let fail_count: Option<i64> = row.get(8)?;
let marginal_count: Option<i64> = row.get(9)?;
Ok(ReportStats {
total_count: total as usize,
avg_net_profit: row.get(1)?,
avg_profit_factor: row.get(2)?,
avg_max_dd_pct: row.get(3)?,
avg_win_rate_pct: row.get(4)?,
avg_sharpe_ratio: row.get(5)?,
profitable_count: profitable.unwrap_or(0) as usize,
pass_verdict_count: pass_count.unwrap_or(0) as usize,
fail_verdict_count: fail_count.unwrap_or(0) as usize,
marginal_verdict_count: marginal_count.unwrap_or(0) as usize,
})
},
)?;
Ok(stats)
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ReportStats {
pub total_count: usize,
pub avg_net_profit: Option<f64>,
pub avg_profit_factor: Option<f64>,
pub avg_max_dd_pct: Option<f64>,
pub avg_win_rate_pct: Option<f64>,
pub avg_sharpe_ratio: Option<f64>,
pub profitable_count: usize,
pub pass_verdict_count: usize,
pub fail_verdict_count: usize,
pub marginal_verdict_count: usize,
}
+175 -9
View File
@@ -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" }
}
}
})
+84 -7
View File
@@ -3,7 +3,87 @@ use serde_json::{json, Value};
pub fn tool_run_backtest() -> Value {
json!({
"name": "run_backtest",
"description": "Run a complete MT5 backtest pipeline: compile → clean cache → backtest → extract → analyze",
"description": "Full backtest pipeline: compile EA → clean cache → run backtest → extract results → analyze. Use this when you have modified the EA source code.",
"inputSchema": {
"type": "object",
"required": ["expert"],
"properties": {
"expert": { "type": "string", "description": "EA name without path or extension" },
"symbol": { "type": "string", "description": "Trading symbol (default: from config or first available)" },
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD (default: past complete month)" },
"to_date": { "type": "string", "description": "End date YYYY.MM.DD (default: past complete month)" },
"timeframe": { "type": "string", "enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1"], "description": "Chart timeframe (default: M5)" },
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" },
"model": { "type": "integer", "enum": [0, 1, 2], "description": "Tick model: 0=Every tick, 1=OHLC, 2=Open prices" },
"set_file": { "type": "string", "description": "Path to .set parameter file for EA inputs" },
"skip_compile": { "type": "boolean", "description": "Skip compilation (use existing .ex5)" },
"skip_clean": { "type": "boolean", "description": "Skip cache cleaning" },
"skip_analyze": { "type": "boolean", "description": "Skip analysis phase" },
"deep": { "type": "boolean", "description": "Run deep analysis with extra metrics" },
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest completes" },
"kill_existing": { "type": "boolean", "description": "Kill any running MT5 instance first" },
"timeout": { "type": "integer", "description": "Max wait time in seconds (default: 900)" },
"gui": { "type": "boolean", "description": "Enable MT5 visualization window" },
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10, set to 0 for default 10s)" }
}
}
})
}
pub fn tool_run_backtest_quick() -> Value {
json!({
"name": "run_backtest_quick",
"description": "Quick backtest using pre-compiled EA: clean cache → run backtest → extract → analyze. Skips compilation. Use when EA code hasn't changed.",
"inputSchema": {
"type": "object",
"required": ["expert"],
"properties": {
"expert": { "type": "string", "description": "EA name without path or extension (must have .ex5 compiled)" },
"symbol": { "type": "string", "description": "Trading symbol (default: from config or first available)" },
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD (default: past complete month)" },
"to_date": { "type": "string", "description": "End date YYYY.MM.DD (default: past complete month)" },
"timeframe": { "type": "string", "enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1"], "description": "Chart timeframe (default: M5)" },
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" },
"model": { "type": "integer", "enum": [0, 1, 2], "description": "Tick model" },
"set_file": { "type": "string", "description": "Path to .set parameter file for EA inputs" },
"deep": { "type": "boolean", "description": "Run deep analysis" },
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest" },
"timeout": { "type": "integer", "description": "Max wait time in seconds (default: 900)" },
"gui": { "type": "boolean", "description": "Enable MT5 visualization" },
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" }
}
}
})
}
pub fn tool_run_backtest_only() -> Value {
json!({
"name": "run_backtest_only",
"description": "Backtest only: just run backtest and extract results. No compile, no analysis. Fastest option when you just need raw trade data.",
"inputSchema": {
"type": "object",
"required": ["expert"],
"properties": {
"expert": { "type": "string", "description": "EA name without path or extension (must have .ex5 compiled)" },
"symbol": { "type": "string", "description": "Trading symbol (default: from config)" },
"from_date": { "type": "string", "description": "Start date YYYY.MM.DD" },
"to_date": { "type": "string", "description": "End date YYYY.MM.DD" },
"timeframe": { "type": "string", "enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1"], "description": "Chart timeframe (default: M5)" },
"deposit": { "type": "integer", "description": "Initial deposit (default: 10000)" },
"model": { "type": "integer", "enum": [0, 1, 2], "description": "Tick model" },
"set_file": { "type": "string", "description": "Path to .set parameter file" },
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest" },
"timeout": { "type": "integer", "description": "Max wait time (default: 900)" },
"gui": { "type": "boolean", "description": "Enable MT5 visualization" }
}
}
})
}
pub fn tool_launch_backtest() -> Value {
json!({
"name": "launch_backtest",
"description": "Launch MT5 backtest in fire-and-forget mode: compile → clean cache → launch MT5 backtest, then return immediately. Use get_backtest_status to poll for completion.",
"inputSchema": {
"type": "object",
"required": ["expert"],
@@ -18,12 +98,9 @@ pub fn tool_run_backtest() -> Value {
"set_file": { "type": "string", "description": "Path to .set parameter file" },
"skip_compile": { "type": "boolean" },
"skip_clean": { "type": "boolean" },
"skip_analyze": { "type": "boolean" },
"deep": { "type": "boolean", "description": "Run deep analysis" },
"shutdown": { "type": "boolean", "description": "Close MT5 after backtest" },
"kill_existing": { "type": "boolean" },
"timeout": { "type": "integer" },
"gui": { "type": "boolean" }
"timeout": { "type": "integer", "description": "Max time in seconds to wait for backtest (default: 900)" },
"gui": { "type": "boolean", "description": "Enable visualization during backtest" },
"startup_delay_secs": { "type": "integer", "description": "Seconds to wait for MT5 initialization (default: 10)" }
}
}
})
+40 -5
View File
@@ -12,9 +12,12 @@ pub mod utility;
pub fn get_tools_list() -> Value {
let tools = vec![
// Backtest
backtest::tool_run_backtest(),
backtest::tool_get_backtest_status(),
// Backtest - Granular options
backtest::tool_run_backtest(), // Full pipeline: compile + clean + backtest + extract + analyze
backtest::tool_run_backtest_quick(), // Quick: skip compile, do clean + backtest + extract + analyze
backtest::tool_run_backtest_only(), // Minimal: skip compile, do clean + backtest + extract only
backtest::tool_launch_backtest(), // Fire-and-forget: compile + clean + launch MT5
backtest::tool_get_backtest_status(), // Poll for completion
backtest::tool_cache_status(),
backtest::tool_clean_cache(),
// Optimization
@@ -22,7 +25,7 @@ pub fn get_tools_list() -> Value {
optimization::tool_get_optimization_status(),
optimization::tool_get_optimization_results(),
optimization::tool_list_jobs(),
// Analytics (9 tools)
// Analytics (19 tools)
analytics::tool_analyze_report(),
analytics::tool_analyze_monthly_pnl(),
analytics::tool_analyze_drawdown_events(),
@@ -32,6 +35,17 @@ pub fn get_tools_list() -> Value {
analytics::tool_analyze_direction_bias(),
analytics::tool_analyze_streaks(),
analytics::tool_analyze_concurrent_peak(),
// Deal query tools (10)
analytics::tool_list_deals(),
analytics::tool_search_deals_by_comment(),
analytics::tool_search_deals_by_magic(),
analytics::tool_analyze_profit_distribution(),
analytics::tool_analyze_time_performance(),
analytics::tool_analyze_hold_time_distribution(),
analytics::tool_analyze_layer_performance(),
analytics::tool_analyze_volume_vs_profit(),
analytics::tool_analyze_costs(),
analytics::tool_analyze_efficiency(),
// Baseline
baseline::tool_compare_baseline(),
// Experts
@@ -49,6 +63,8 @@ pub fn get_tools_list() -> Value {
system::tool_list_symbols(),
system::tool_healthcheck(),
system::tool_get_active_account(),
system::tool_check_update(),
system::tool_update(),
// Utility (8 tools)
utility::tool_check_symbol_data_status(),
utility::tool_get_backtest_history(),
@@ -58,6 +74,16 @@ pub fn get_tools_list() -> Value {
utility::tool_check_mt5_status(),
utility::tool_create_set_template(),
utility::tool_export_report(),
// Debugging/Diagnostics (10 tools)
utility::tool_diagnose_wine(),
utility::tool_get_mt5_logs(),
utility::tool_search_mt5_errors(),
utility::tool_check_mt5_process(),
utility::tool_kill_mt5_process(),
utility::tool_check_system_resources(),
utility::tool_validate_mt5_config(),
utility::tool_get_wine_prefix_info(),
utility::tool_get_backtest_crash_info(),
// Set Files
setfiles::tool_read_set_file(),
setfiles::tool_write_set_file(),
@@ -67,7 +93,7 @@ pub fn get_tools_list() -> Value {
setfiles::tool_describe_sweep(),
setfiles::tool_list_set_files(),
setfiles::tool_set_from_optimization(),
// Reports (11 tools)
// Reports (19 tools)
reports::tool_list_reports(),
reports::tool_search_reports(),
reports::tool_get_latest_report(),
@@ -78,6 +104,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)
+15 -15
View File
@@ -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"
}
+144
View File
@@ -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)" }
}
}
})
}
+22
View File
@@ -45,3 +45,25 @@ pub fn tool_get_active_account() -> Value {
}
})
}
pub fn tool_check_update() -> Value {
json!({
"name": "check_update",
"description": "Check if a newer version of mt5-quant is available on GitHub. A background check runs automatically on the first tool call of each session; this tool returns that cached result instantly or fetches it on demand.",
"inputSchema": {
"type": "object",
"properties": {}
}
})
}
pub fn tool_update() -> Value {
json!({
"name": "update",
"description": "Download and install the latest mt5-quant binary from GitHub Releases, then replace the current executable in place. Restart the MCP connection after updating to load the new version.",
"inputSchema": {
"type": "object",
"properties": {}
}
})
}
+146
View File
@@ -165,3 +165,149 @@ pub fn tool_export_report() -> Value {
}
})
}
pub fn tool_diagnose_wine() -> Value {
json!({
"name": "diagnose_wine",
"description": "Check Wine installation, version, and prefix health. Reports errors, warnings, and recent Wine errors.",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_get_mt5_logs() -> Value {
json!({
"name": "get_mt5_logs",
"description": "Get MT5 terminal, tester, or MetaEditor logs with optional search filtering",
"inputSchema": {
"type": "object",
"properties": {
"log_type": {
"type": "string",
"enum": ["terminal", "tester", "metaeditor"],
"default": "terminal",
"description": "Type of log to retrieve"
},
"lines": {
"type": "integer",
"default": 100,
"description": "Number of lines to return (from end of log)"
},
"search": {
"type": "string",
"description": "Optional search term to filter log lines"
}
}
}
})
}
pub fn tool_search_mt5_errors() -> Value {
json!({
"name": "search_mt5_errors",
"description": "Search MT5 logs for error patterns (error, failed, crash, exception, etc.) in recent hours",
"inputSchema": {
"type": "object",
"properties": {
"hours_back": {
"type": "integer",
"default": 24,
"description": "Hours to search back in logs"
},
"max_errors": {
"type": "integer",
"default": 50,
"description": "Maximum number of errors to return"
}
}
}
})
}
pub fn tool_check_mt5_process() -> Value {
json!({
"name": "check_mt5_process",
"description": "Check if MT5 processes are running, get process info (PID, CPU, memory usage)",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_kill_mt5_process() -> Value {
json!({
"name": "kill_mt5_process",
"description": "Kill stuck MT5 processes. Use force=true for stuck wineserver.",
"inputSchema": {
"type": "object",
"properties": {
"pid": {
"type": "string",
"description": "Optional specific PID to kill"
},
"force": {
"type": "boolean",
"default": false,
"description": "Use SIGKILL (-9) instead of SIGTERM (-15), also kills wineserver"
}
}
}
})
}
pub fn tool_check_system_resources() -> Value {
json!({
"name": "check_system_resources",
"description": "Check disk space, memory, and CPU. Warns if resources are low for MT5 operations.",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_validate_mt5_config() -> Value {
json!({
"name": "validate_mt5_config",
"description": "Validate MT5 configuration files (terminal.ini, tester settings). Reports errors and warnings.",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_get_wine_prefix_info() -> Value {
json!({
"name": "get_wine_prefix_info",
"description": "Get detailed Wine prefix information: Windows version, installed programs, registry files, drive_c size",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_get_backtest_crash_info() -> Value {
json!({
"name": "get_backtest_crash_info",
"description": "Investigate backtest crashes/failures. Checks for incomplete markers, missing deals.csv, error logs. Can scan recent reports.",
"inputSchema": {
"type": "object",
"properties": {
"report_dir": {
"type": "string",
"description": "Optional specific report directory to check"
},
"check_recent": {
"type": "boolean",
"default": true,
"description": "Also check recent reports for failures"
},
"hours_back": {
"type": "integer",
"default": 6,
"description": "Hours back to check for recent failures"
}
}
}
})
}
+229 -230
View File
@@ -6,304 +6,303 @@ use std::path::Path;
use crate::analytics::DealAnalyzer;
use crate::models::deals::Deal;
use crate::models::metrics::Metrics;
use crate::models::Config;
use crate::storage::ReportDb;
/// Helper to load deals and metrics from report directory
fn load_report_data(report_dir: &str) -> Result<(Vec<Deal>, Metrics)> {
let deals_csv = Path::new(report_dir).join("deals.csv");
let metrics_json = Path::new(report_dir).join("metrics.json");
// ── Internal helpers ──────────────────────────────────────────────────────────
if !deals_csv.exists() {
return Err(anyhow::anyhow!("deals.csv not found in {}", report_dir));
}
fn required_str<'a>(args: &'a Value, key: &str) -> Result<&'a str> {
args.get(key)
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("{} is required", key))
}
let deals = read_deals_from_csv(&deals_csv)?;
let metrics = if metrics_json.exists() {
let content = fs::read_to_string(&metrics_json)?;
serde_json::from_str(&content)?
fn ok_response(data: Value) -> Value {
json!({
"content": [{ "type": "text", "text": data.to_string() }],
"isError": false
})
}
fn err_response(msg: impl std::fmt::Display) -> Value {
json!({
"content": [{ "type": "text", "text": msg.to_string() }],
"isError": true
})
}
/// Resolve a report from args (report_id > report_dir > latest).
/// Returns (deals, metrics, report_dir).
fn resolve_report(args: &Value) -> Result<(Vec<Deal>, Metrics, String)> {
let db = ReportDb::new(&Config::db_path());
db.init()?;
let entry = if let Some(id) = args.get("report_id").and_then(|v| v.as_str()) {
db.get_by_id(id)?
.ok_or_else(|| anyhow::anyhow!("Report '{}' not found in DB", id))?
} else if let Some(dir) = args.get("report_dir").and_then(|v| v.as_str()) {
db.get_by_report_dir(dir)?
.ok_or_else(|| anyhow::anyhow!(
"No DB entry for report_dir '{}'. This report may predate DB storage.", dir
))?
} else {
db.get_latest()?
.ok_or_else(|| anyhow::anyhow!("No reports in DB. Run a backtest first."))?
};
let deals = db.get_deals(&entry.id)?;
let metrics_path = Path::new(&entry.report_dir).join("metrics.json");
let metrics = if metrics_path.exists() {
serde_json::from_str(&fs::read_to_string(&metrics_path)?)?
} else {
Metrics::default()
};
Ok((deals, metrics))
Ok((deals, metrics, entry.report_dir))
}
fn prepare_analysis(args: &Value) -> Result<(Vec<Deal>, Metrics, DealAnalyzer, String)> {
let (deals, metrics, report_dir) = resolve_report(args)?;
Ok((deals, metrics, DealAnalyzer::new(), report_dir))
}
// ── Composite analytics ───────────────────────────────────────────────────────
pub async fn handle_analyze_report(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, metrics, analyzer, report_dir) = prepare_analysis(args)?;
let (deals, metrics) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
// Check if specific analytics requested
let requested: Option<HashSet<String>> = args.get("analytics")
.and_then(|v| v.as_array())
.map(|arr| arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect());
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect());
let top_losses_limit = args.get("top_losses_limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
let all = requested.is_none();
let req = |name: &str| all || requested.as_ref().map(|s| s.contains(name)).unwrap_or(false);
// Build selective result
let mut result = json!({});
if req("monthly_pnl") || all {
result["monthly"] = json!(analyzer.monthly_pnl(&deals));
}
if req("drawdown_events") || all {
result["dd_events"] = json!(analyzer.reconstruct_dd_events(&deals, &metrics));
}
if req("top_losses") || all {
result["top_losses"] = json!(analyzer.top_losses(&deals, top_losses_limit));
}
if req("loss_sequences") || all {
result["loss_sequences"] = json!(analyzer.loss_sequences(&deals));
}
if req("position_pairs") || all {
result["position_pairs"] = json!(analyzer.position_pairs(&deals));
}
if req("direction_bias") || all {
result["direction_bias"] = json!(analyzer.direction_bias(&deals));
}
if req("streak_analysis") || all {
result["streak_analysis"] = json!(analyzer.streak_analysis(&deals));
}
if req("concurrent_peak") || all {
result["concurrent_peak"] = json!(analyzer.concurrent_peak(&deals));
}
if req("monthly_pnl") { result["monthly"] = json!(analyzer.monthly_pnl(&deals)); }
if req("drawdown_events") { result["dd_events"] = json!(analyzer.reconstruct_dd_events(&deals, &metrics)); }
if req("top_losses") { result["top_losses"] = json!(analyzer.top_losses(&deals, top_losses_limit)); }
if req("loss_sequences") { result["loss_sequences"] = json!(analyzer.loss_sequences(&deals)); }
if req("position_pairs") { result["position_pairs"] = json!(analyzer.position_pairs(&deals)); }
if req("direction_bias") { result["direction_bias"] = json!(analyzer.direction_bias(&deals)); }
if req("streak_analysis") { result["streak_analysis"] = json!(analyzer.streak_analysis(&deals)); }
if req("concurrent_peak") { result["concurrent_peak"] = json!(analyzer.concurrent_peak(&deals)); }
let analysis_path = Path::new(report_dir).join("analysis.json");
let analysis_path = Path::new(&report_dir).join("analysis.json");
fs::write(&analysis_path, serde_json::to_string_pretty(&result)?)?;
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"analysis_file": analysis_path.to_string_lossy(),
"analytics_run": requested.map(|s| s.iter().cloned().collect::<Vec<_>>()).unwrap_or_else(|| vec!["all".to_string()]),
"summary": result,
}).to_string() }],
"isError": false
}))
}
fn read_deals_from_csv(path: &Path) -> Result<Vec<Deal>> {
let content = fs::read_to_string(path)?;
let mut deals = Vec::new();
let mut lines = content.lines();
let _header = lines.next();
for line in lines {
let parts: Vec<&str> = line.split(',').collect();
if parts.len() >= 12 {
deals.push(Deal {
time: parts[0].to_string(),
deal: parts[1].to_string(),
symbol: parts[2].to_string(),
deal_type: parts[3].to_string(),
entry: parts[4].to_string(),
volume: parts[5].parse().unwrap_or(0.0),
price: parts[6].parse().unwrap_or(0.0),
order: parts[7].to_string(),
commission: parts[8].parse().unwrap_or(0.0),
swap: parts[9].parse().unwrap_or(0.0),
profit: parts[10].parse().unwrap_or(0.0),
balance: parts[11].parse().unwrap_or(0.0),
comment: parts.get(12).unwrap_or(&"").to_string(),
magic: parts.get(13).map(|s| s.to_string()),
});
}
}
Ok(deals)
Ok(ok_response(json!({
"success": true,
"analysis_file": analysis_path.to_string_lossy(),
"analytics_run": requested.map(|s| s.iter().cloned().collect::<Vec<_>>()).unwrap_or_else(|| vec!["all".to_string()]),
"summary": result,
})))
}
pub async fn handle_compare_baseline(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (_, _, _, report_dir) = prepare_analysis(args)?;
let baseline_path = Path::new("config/baseline.json");
let metrics_path = Path::new(report_dir).join("metrics.json");
let metrics_path = Path::new(&report_dir).join("metrics.json");
if !baseline_path.exists() {
return Ok(json!({
"content": [{ "type": "text", "text": "No baseline.json found in config/" }],
"isError": false
}));
return Ok(ok_response(json!("No baseline.json found in config/")));
}
let baseline: Value = serde_json::from_str(&fs::read_to_string(baseline_path)?)?;
let current: Value = serde_json::from_str(&fs::read_to_string(metrics_path)?)?;
let comparison = json!({
Ok(ok_response(json!({
"baseline": baseline,
"current": current,
"improvements": {
"profit": current.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0)
"profit": current.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0)
- baseline.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0),
"drawdown": current.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0)
- baseline.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0),
}
});
Ok(json!({
"content": [{ "type": "text", "text": comparison.to_string() }],
"isError": false
}))
})))
}
// === Granular Analytics Handlers ===
// ── Granular analytics handlers ───────────────────────────────────────────────
pub async fn handle_analyze_monthly_pnl(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.monthly_pnl(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"monthly_pnl": result,
}).to_string() }],
"isError": false
}))
let (deals, _, analyzer, _) = prepare_analysis(args)?;
Ok(ok_response(json!({ "success": true, "monthly_pnl": analyzer.monthly_pnl(&deals) })))
}
pub async fn handle_analyze_drawdown_events(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, metrics) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.reconstruct_dd_events(&deals, &metrics);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"drawdown_events": result,
}).to_string() }],
"isError": false
}))
let (deals, metrics, analyzer, _) = prepare_analysis(args)?;
Ok(ok_response(json!({ "success": true, "drawdown_events": analyzer.reconstruct_dd_events(&deals, &metrics) })))
}
pub async fn handle_analyze_top_losses(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.top_losses(&deals, limit);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"limit": limit,
"top_losses": result,
}).to_string() }],
"isError": false
}))
let (deals, _, analyzer, _) = prepare_analysis(args)?;
Ok(ok_response(json!({ "success": true, "limit": limit, "top_losses": analyzer.top_losses(&deals, limit) })))
}
pub async fn handle_analyze_loss_sequences(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.loss_sequences(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"loss_sequences": result,
}).to_string() }],
"isError": false
}))
let (deals, _, analyzer, _) = prepare_analysis(args)?;
Ok(ok_response(json!({ "success": true, "loss_sequences": analyzer.loss_sequences(&deals) })))
}
pub async fn handle_analyze_position_pairs(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.position_pairs(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"position_pairs": result,
}).to_string() }],
"isError": false
}))
let (deals, _, analyzer, _) = prepare_analysis(args)?;
Ok(ok_response(json!({ "success": true, "position_pairs": analyzer.position_pairs(&deals) })))
}
pub async fn handle_analyze_direction_bias(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.direction_bias(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"direction_bias": result,
}).to_string() }],
"isError": false
}))
let (deals, _, analyzer, _) = prepare_analysis(args)?;
Ok(ok_response(json!({ "success": true, "direction_bias": analyzer.direction_bias(&deals) })))
}
pub async fn handle_analyze_streaks(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.streak_analysis(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"streak_analysis": result,
}).to_string() }],
"isError": false
}))
let (deals, _, analyzer, _) = prepare_analysis(args)?;
Ok(ok_response(json!({ "success": true, "streak_analysis": analyzer.streak_analysis(&deals) })))
}
pub async fn handle_analyze_concurrent_peak(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.concurrent_peak(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"concurrent_peak": result,
}).to_string() }],
"isError": false
}))
let (deals, _, analyzer, _) = prepare_analysis(args)?;
Ok(ok_response(json!({ "success": true, "concurrent_peak": analyzer.concurrent_peak(&deals) })))
}
// 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 {
d.entry.to_lowercase().contains("out") && d.profit != 0.0
}
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(""); }
+225 -12
View File
@@ -2,7 +2,9 @@ use anyhow::Result;
use serde_json::{json, Value};
use std::fs;
use std::path::Path;
use std::process::Command;
use crate::models::Config;
use crate::models::report::BacktestJob;
use crate::pipeline::backtest::{BacktestParams, BacktestPipeline};
/// Pre-flight check result for backtest readiness
@@ -145,12 +147,12 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value>
}));
}
// Date defaulting: past complete calendar month
// Date defaulting: current month
let (from_date, to_date) = {
let f = args.get("from_date").and_then(|v| v.as_str()).unwrap_or("");
let t = args.get("to_date").and_then(|v| v.as_str()).unwrap_or("");
if f.is_empty() || t.is_empty() {
super::past_complete_month()
super::current_month()
} else {
(f.to_string(), t.to_string())
}
@@ -174,6 +176,7 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value>
kill_existing: args.get("kill_existing").and_then(|v| v.as_bool()).unwrap_or(false),
timeout: args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900),
gui: args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false),
startup_delay_secs: args.get("startup_delay_secs").and_then(|v| v.as_u64()).unwrap_or(0),
};
let pipeline = BacktestPipeline::new(config.clone());
@@ -190,38 +193,248 @@ pub async fn handle_run_backtest(config: &Config, args: &Value) -> Result<Value>
}))
}
pub async fn handle_run_backtest_quick(config: &Config, args: &Value) -> Result<Value> {
// Quick backtest: skip compile, do clean → backtest → extract → analyze
let mut args = args.clone();
if let Some(obj) = args.as_object_mut() {
obj.insert("skip_compile".to_string(), json!(true));
// keep skip_analyze as false (default) to run analysis
}
handle_run_backtest(config, &args).await
}
pub async fn handle_run_backtest_only(config: &Config, args: &Value) -> Result<Value> {
// Backtest only: skip compile, skip analyze - just backtest and extract
let mut args = args.clone();
if let Some(obj) = args.as_object_mut() {
obj.insert("skip_compile".to_string(), json!(true));
obj.insert("skip_analyze".to_string(), json!(true));
}
handle_run_backtest(config, &args).await
}
pub async fn handle_launch_backtest(handler: &crate::tools::handlers::ToolHandler, args: &Value) -> Result<Value> {
let expert = args.get("expert")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("expert is required"))?;
// Run pre-flight check
let preflight = BacktestPreflight::check(&handler.config, expert);
// Check account session
if preflight.account.is_none() {
return Ok(json!({
"content": [{ "type": "text", "text": json!({
"error": "No active MT5 account session detected.",
"hint": "Open MT5 and login to your trading account before running backtests."
}).to_string() }],
"isError": true
}));
}
// Get symbol
let requested_symbol = args.get("symbol")
.and_then(|v| v.as_str())
.unwrap_or("");
let symbol = if requested_symbol.is_empty() {
handler.config.backtest_symbol.clone()
.or_else(|| preflight.available_symbols.first().cloned())
.unwrap_or_else(|| "EURUSD".to_string())
} else {
requested_symbol.to_string()
};
// EA existence check
if !preflight.ea_exists {
return Ok(json!({
"content": [{ "type": "text", "text": json!({
"error": format!("EA '{}' not found in Experts directory.", expert),
"hint": "Use search_experts or list_experts to find available EAs."
}).to_string() }],
"isError": true
}));
}
// Date defaulting
let (from_date, to_date) = {
let f = args.get("from_date").and_then(|v| v.as_str()).unwrap_or("");
let t = args.get("to_date").and_then(|v| v.as_str()).unwrap_or("");
if f.is_empty() || t.is_empty() {
super::current_month()
} else {
(f.to_string(), t.to_string())
}
};
let params = BacktestParams {
expert: expert.to_string(),
symbol: symbol.to_string(),
from_date: from_date.to_string(),
to_date: to_date.to_string(),
timeframe: args.get("timeframe").and_then(|v| v.as_str()).unwrap_or("M5").to_string(),
deposit: args.get("deposit").and_then(|v| v.as_u64()).unwrap_or(10000) as u32,
model: args.get("model").and_then(|v| v.as_u64()).unwrap_or(0) as u8,
leverage: args.get("leverage").and_then(|v| v.as_u64()).unwrap_or(500) as u32,
set_file: args.get("set_file").and_then(|v| v.as_str()).map(|s| s.to_string()),
skip_compile: args.get("skip_compile").and_then(|v| v.as_bool()).unwrap_or(false),
skip_clean: args.get("skip_clean").and_then(|v| v.as_bool()).unwrap_or(false),
skip_analyze: true, // Not needed for launch mode
deep_analyze: false,
shutdown: false, // Don't shutdown so we can poll
kill_existing: false,
timeout: args.get("timeout").and_then(|v| v.as_u64()).unwrap_or(900),
gui: args.get("gui").and_then(|v| v.as_bool()).unwrap_or(false),
startup_delay_secs: args.get("startup_delay_secs").and_then(|v| v.as_u64()).unwrap_or(0),
};
let pipeline = if let Some(ref callback) = handler.notification_callback {
BacktestPipeline::with_notification_callback(handler.config.clone(), callback.clone())
} else {
BacktestPipeline::new(handler.config.clone())
};
let job = pipeline.launch_backtest(params).await?;
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"message": "Backtest launched successfully. Use get_backtest_status to poll for completion.",
"report_id": job.report_id,
"report_dir": job.report_dir,
"expert": job.expert,
"symbol": job.symbol,
"timeframe": job.timeframe,
"launched_at": job.launched_at,
"timeout_seconds": job.timeout_seconds,
"poll_hint": "Call get_backtest_status with report_dir to check progress"
}).to_string() }],
"isError": false
}))
}
pub async fn handle_get_backtest_status(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.unwrap_or("latest");
let progress_file = Path::new(report_dir).join("progress.log");
let report_path = Path::new(report_dir);
let progress_file = report_path.join("progress.log");
let job_file = report_path.join("job.json");
let status = if progress_file.exists() {
// Load job info if available
let job: Option<BacktestJob> = if job_file.exists() {
fs::read_to_string(&job_file)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
} else {
None
};
// Check progress log for stage
let (stage, progress_lines) = if progress_file.exists() {
if let Ok(content) = fs::read_to_string(&progress_file) {
let last_line = content.lines().last().unwrap_or("");
if last_line.contains("DONE") {
"completed"
} else {
"running"
}
let lines: Vec<&str> = content.lines().collect();
let last_stage = lines.last()
.and_then(|l| l.split_whitespace().next())
.unwrap_or("UNKNOWN");
(last_stage.to_string(), lines.len())
} else {
"unknown"
("UNKNOWN".to_string(), 0)
}
} else {
("NOT_STARTED".to_string(), 0)
};
// Check if MT5 is running
let mt5_running = is_mt5_running();
// Check if report file exists
let report_found = job.as_ref()
.map(|j| Path::new(&j.expected_report_path).exists())
.unwrap_or(false);
// Check for completed artifacts
let metrics_exists = report_path.join("metrics.json").exists();
let deals_exists = report_path.join("deals.csv").exists();
let is_complete = stage == "DONE" || (report_found && metrics_exists);
// Calculate elapsed time if job exists
let elapsed_seconds = job.as_ref()
.and_then(|j| {
chrono::DateTime::parse_from_rfc3339(&j.launched_at)
.ok()
.map(|t| (chrono::Utc::now() - t.with_timezone(&chrono::Utc)).num_seconds())
})
.unwrap_or(0);
// Determine status message
let status_msg = if is_complete {
"completed"
} else if stage == "BACKTEST" && mt5_running {
"running"
} else if stage == "BACKTEST" && !mt5_running && !report_found {
"failed"
} else if progress_lines > 0 {
"in_progress"
} else {
"not_started"
};
let message = if is_complete {
"Backtest completed successfully"
} else if stage == "BACKTEST" && mt5_running {
"MT5 is running the backtest"
} else if stage == "BACKTEST" && !mt5_running {
"MT5 process exited but report not found - backtest may have failed"
} else {
&format!("Backtest is at stage: {}", stage)
};
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"report_dir": report_dir,
"status": status
"status": status_msg,
"stage": stage,
"is_complete": is_complete,
"mt5_running": mt5_running,
"report_found": report_found,
"metrics_extracted": metrics_exists,
"deals_extracted": deals_exists,
"elapsed_seconds": elapsed_seconds,
"message": message,
"job": job.map(|j| {
json!({
"report_id": j.report_id,
"expert": j.expert,
"symbol": j.symbol,
"timeframe": j.timeframe,
"launched_at": j.launched_at,
"timeout_seconds": j.timeout_seconds
})
})
}).to_string() }],
"isError": false
}))
}
/// Check if MT5 is currently running
fn is_mt5_running() -> bool {
let patterns = if cfg!(target_os = "macos") {
vec!["MetaTrader 5\\.app", "terminal64\\.exe"]
} else {
vec!["terminal64\\.exe", "metatrader"]
};
patterns.iter().any(|pat| {
Command::new("pgrep")
.args(["-f", pat])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
})
}
pub async fn handle_cache_status(config: &Config) -> Result<Value> {
let cache_dir = config.tester_cache_dir.as_ref()
.map(|s| Path::new(s))
+202 -458
View File
@@ -4,212 +4,209 @@ use walkdir::WalkDir;
use crate::compile::MqlCompiler;
use crate::models::Config;
pub async fn handle_list_experts(config: &Config, args: &Value) -> Result<Value> {
let filter = args.get("filter").and_then(|v| v.as_str());
let mut experts = Vec::new();
if let Some(experts_dir) = &config.experts_dir {
for entry in WalkDir::new(experts_dir).max_depth(3) {
if let Ok(entry) = entry {
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(name) = path.file_stem() {
let name_str = name.to_string_lossy().to_string();
let is_compiled = path.extension()
.map(|e| e == "ex5")
.unwrap_or(false);
if let Some(filter_str) = filter {
if !name_str.to_lowercase().contains(&filter_str.to_lowercase()) {
continue;
}
}
experts.push(json!({
"name": name_str,
"compiled": is_compiled,
"path": path.to_string_lossy().to_string(),
}));
const BUILTIN_INDICATORS: &[&str] = &[
"Accelerator", "Accumulation", "ADX", "Alligator", "AO", "ATR",
"Bands", "Bears", "Bulls", "CCI", "DeMarker", "Envelopes", "Force",
"Fractals", "Gator", "Ichimoku", "MA", "MACD", "MFI", "Momentum",
"OBV", "OsMA", "RSI", "RVI", "SAR", "StdDev", "Stochastic", "WPR",
];
// ── Internal helpers ──────────────────────────────────────────────────────────
/// Walk a MQL directory and return file entries matching an optional filter.
/// `type_label` is included as a `"type"` field when provided (e.g. "custom").
fn scan_mql_dir(dir: Option<&String>, filter: Option<&str>, type_label: Option<&str>) -> Vec<Value> {
let Some(dir) = dir else { return Vec::new() };
let filter_lower = filter.map(|f| f.to_lowercase());
let mut items: Vec<Value> = WalkDir::new(dir)
.max_depth(3)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file())
.filter_map(|e| {
let path = e.path();
let name = path.file_stem()?.to_string_lossy().into_owned();
if let Some(ref f) = filter_lower {
if !name.to_lowercase().contains(f.as_str()) {
return None;
}
}
}
}
experts.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"count": experts.len(),
"experts": experts,
}).to_string() }],
let is_compiled = path.extension().map(|ext| ext == "ex5").unwrap_or(false);
let mut obj = json!({
"name": name,
"compiled": is_compiled,
"path": path.to_string_lossy().as_ref(),
});
if let Some(t) = type_label {
obj["type"] = json!(t);
}
Some(obj)
})
.collect();
items.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
items
}
fn ok_response(data: Value) -> Value {
json!({
"content": [{ "type": "text", "text": data.to_string() }],
"isError": false
}))
})
}
fn err_response(msg: impl std::fmt::Display) -> Value {
json!({
"content": [{ "type": "text", "text": msg.to_string() }],
"isError": true
})
}
/// Shared implementation for copy_indicator_to_project / copy_script_to_project.
/// `default_fallback` is used as the stem when the source has no filename.
fn copy_mql_to_project(config: &Config, args: &Value, default_fallback: &str) -> Result<Value> {
use std::path::PathBuf;
let source_path = args.get("source_path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("source_path is required"))?;
let target_name = args.get("target_name").and_then(|v| v.as_str());
let project_dir = config.project_dir.as_ref()
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.ok_or_else(|| anyhow::anyhow!("Cannot determine project directory"))?;
let source = PathBuf::from(source_path);
if !source.exists() {
return Ok(err_response(
serde_json::to_string(&json!({ "success": false, "error": format!("Source file not found: {}", source_path) })).unwrap_or_default()
));
}
let ext = source.extension().and_then(|e| e.to_str()).unwrap_or("mq5");
let target_filename = match target_name {
Some(name) => format!("{}.{}", name, ext),
None => source.file_name()
.and_then(|n| n.to_str())
.map(|n| n.to_string())
.unwrap_or_else(|| format!("{}.{}", default_fallback, ext)),
};
let destination = project_dir.join(&target_filename);
match std::fs::copy(&source, &destination) {
Ok(bytes) => Ok(ok_response(json!({
"success": true,
"source": source_path,
"destination": destination.to_string_lossy().as_ref(),
"bytes_copied": bytes,
}))),
Err(e) => Ok(err_response(
serde_json::to_string(&json!({ "success": false, "error": format!("Failed to copy file: {}", e) })).unwrap_or_default()
)),
}
}
// ── Public handlers ───────────────────────────────────────────────────────────
pub async fn handle_list_experts(config: &Config, args: &Value) -> Result<Value> {
let filter = args.get("filter").and_then(|v| v.as_str());
let experts = scan_mql_dir(config.experts_dir.as_ref(), filter, None);
Ok(ok_response(json!({
"success": true,
"count": experts.len(),
"experts": experts,
})))
}
pub async fn handle_search_experts(config: &Config, args: &Value) -> Result<Value> {
let pattern = args.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
let pattern_lower = pattern.to_lowercase();
let mut matches = Vec::new();
if let Some(experts_dir) = &config.experts_dir {
for entry in WalkDir::new(experts_dir).max_depth(3) {
if let Ok(entry) = entry {
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(name) = path.file_stem() {
let name_str = name.to_string_lossy().to_string();
if name_str.to_lowercase().contains(&pattern_lower) {
let is_compiled = path.extension()
.map(|e| e == "ex5")
.unwrap_or(false);
matches.push(json!({
"name": name_str,
"path": path.to_string_lossy().to_string(),
"compiled": is_compiled,
}));
}
}
}
}
}
matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"pattern": pattern,
"count": matches.len(),
"matches": matches,
}).to_string() }],
"isError": false
}))
let matches = scan_mql_dir(config.experts_dir.as_ref(), Some(pattern), None);
Ok(ok_response(json!({
"success": true,
"pattern": pattern,
"count": matches.len(),
"matches": matches,
})))
}
pub async fn handle_list_indicators(config: &Config, args: &Value) -> Result<Value> {
let filter = args.get("filter").and_then(|v| v.as_str());
let include_builtin = args.get("include_builtin").and_then(|v| v.as_bool()).unwrap_or(false);
let mut indicators = Vec::new();
// List custom indicators
if let Some(indicators_dir) = &config.indicators_dir {
for entry in WalkDir::new(indicators_dir).max_depth(3) {
if let Ok(entry) = entry {
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(name) = path.file_stem() {
let name_str = name.to_string_lossy().to_string();
let is_compiled = path.extension()
.map(|e| e == "ex5")
.unwrap_or(false);
if let Some(filter_str) = filter {
if !name_str.to_lowercase().contains(&filter_str.to_lowercase()) {
continue;
}
}
indicators.push(json!({
"name": name_str,
"compiled": is_compiled,
"type": "custom",
"path": path.to_string_lossy().to_string(),
}));
}
}
}
}
// Add built-in indicators if requested
let mut indicators = scan_mql_dir(config.indicators_dir.as_ref(), filter, Some("custom"));
if include_builtin {
let builtin = vec![
"Accelerator", "Accumulation", "ADX", "Alligator", "AO", "ATR",
"Bands", "Bears", "Bulls", "CCI", "DeMarker", "Envelopes", "Force",
"Fractals", "Gator", "Ichimoku", "MA", "MACD", "MFI", "Momentum",
"OBV", "OsMA", "RSI", "RVI", "SAR", "StdDev", "Stochastic", "WPR",
];
for name in builtin {
if filter.map(|f| name.to_lowercase().contains(&f.to_lowercase())).unwrap_or(true) {
indicators.push(json!({
"name": name,
"compiled": true,
"type": "builtin",
"path": null,
}));
let filter_lower = filter.map(|f| f.to_lowercase());
for &name in BUILTIN_INDICATORS {
if filter_lower.as_ref().map(|f| name.to_lowercase().contains(f.as_str())).unwrap_or(true) {
indicators.push(json!({ "name": name, "compiled": true, "type": "builtin", "path": null }));
}
}
indicators.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
}
indicators.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"count": indicators.len(),
"indicators": indicators,
"custom_dir": config.indicators_dir.clone(),
}).to_string() }],
"isError": false
}))
Ok(ok_response(json!({
"success": true,
"count": indicators.len(),
"indicators": indicators,
"custom_dir": config.indicators_dir.clone(),
})))
}
pub async fn handle_list_scripts(config: &Config, args: &Value) -> Result<Value> {
let filter = args.get("filter").and_then(|v| v.as_str());
let mut scripts = Vec::new();
if let Some(scripts_dir) = &config.scripts_dir {
for entry in WalkDir::new(scripts_dir).max_depth(3) {
if let Ok(entry) = entry {
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(name) = path.file_stem() {
let name_str = name.to_string_lossy().to_string();
let is_compiled = path.extension()
.map(|e| e == "ex5")
.unwrap_or(false);
if let Some(filter_str) = filter {
if !name_str.to_lowercase().contains(&filter_str.to_lowercase()) {
continue;
}
}
scripts.push(json!({
"name": name_str,
"compiled": is_compiled,
"path": path.to_string_lossy().to_string(),
}));
}
let scripts = scan_mql_dir(config.scripts_dir.as_ref(), filter, None);
Ok(ok_response(json!({
"success": true,
"count": scripts.len(),
"scripts": scripts,
"scripts_dir": config.scripts_dir.clone(),
})))
}
pub async fn handle_search_indicators(config: &Config, args: &Value) -> Result<Value> {
let pattern = args.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
let include_builtin = args.get("include_builtin").and_then(|v| v.as_bool()).unwrap_or(false);
let mut matches = scan_mql_dir(config.indicators_dir.as_ref(), Some(pattern), Some("custom"));
if include_builtin {
let pattern_lower = pattern.to_lowercase();
for &name in BUILTIN_INDICATORS {
if name.to_lowercase().contains(&pattern_lower) {
matches.push(json!({ "name": name, "path": null, "type": "builtin", "compiled": true }));
}
}
matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
}
scripts.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"count": scripts.len(),
"scripts": scripts,
"scripts_dir": config.scripts_dir.clone(),
}).to_string() }],
"isError": false
}))
Ok(ok_response(json!({
"success": true,
"pattern": pattern,
"count": matches.len(),
"matches": matches,
})))
}
pub async fn handle_search_scripts(config: &Config, args: &Value) -> Result<Value> {
let pattern = args.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
let matches = scan_mql_dir(config.scripts_dir.as_ref(), Some(pattern), None);
Ok(ok_response(json!({
"success": true,
"pattern": pattern,
"count": matches.len(),
"matches": matches,
})))
}
pub async fn handle_compile_ea(config: &Config, args: &Value) -> Result<Value> {
@@ -218,302 +215,49 @@ pub async fn handle_compile_ea(config: &Config, args: &Value) -> Result<Value> {
let resolved_path: String = if let Some(p) = args.get("expert_path").and_then(|v| v.as_str()) {
p.to_string()
} else if let Some(name) = args.get("expert").and_then(|v| v.as_str()) {
let mut candidates = vec![
PathBuf::from(name).with_extension("mq5"),
];
let mut candidates = vec![PathBuf::from(name).with_extension("mq5")];
if let Some(experts_dir) = &config.experts_dir {
candidates.push(PathBuf::from(experts_dir).join(name).join(format!("{}.mq5", name)));
candidates.push(PathBuf::from(experts_dir).join(format!("{}.mq5", name)));
}
match candidates.into_iter().find(|p| p.exists()) {
Some(p) => p.to_string_lossy().to_string(),
None => return Ok(serde_json::json!({
"content": [{ "type": "text", "text": serde_json::json!({
None => return Ok(err_response(
serde_json::to_string(&json!({
"success": false,
"error": format!("Cannot find {}.mq5 in MT5 Experts dir or current directory", name),
}).to_string() }],
"isError": true
})),
})).unwrap_or_default()
)),
}
} else {
return Err(anyhow::anyhow!("Either 'expert' or 'expert_path' is required"));
};
let compiler = MqlCompiler::new(config.clone());
let expert_path = resolved_path.as_str();
match compiler.compile(&expert_path) {
Ok(result) => {
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": result.success,
"binary_path": result.ex5_path.map(|p| p.to_string_lossy().to_string()),
"binary_size_bytes": result.binary_size,
"files_synced": result.files_synced,
"warnings": result.warnings.len(),
"errors": result.errors.len(),
"error_list": result.errors,
"warning_list": result.warnings,
}).to_string() }],
"isError": !result.success
}))
}
Err(e) => {
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": false,
"error": format!("Compilation failed: {}", e),
}).to_string() }],
"isError": true
}))
}
match compiler.compile(&resolved_path).await {
Ok(result) => Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": result.success,
"binary_path": result.ex5_path.map(|p| p.to_string_lossy().to_string()),
"binary_size_bytes": result.binary_size,
"files_synced": result.files_synced,
"warnings": result.warnings.len(),
"errors": result.errors.len(),
"error_list": result.errors,
"warning_list": result.warnings,
}).to_string() }],
"isError": !result.success
})),
Err(e) => Ok(err_response(
serde_json::to_string(&json!({ "success": false, "error": format!("Compilation failed: {}", e) })).unwrap_or_default()
)),
}
}
pub async fn handle_search_indicators(config: &Config, args: &Value) -> Result<Value> {
let pattern = args.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
let include_builtin = args.get("include_builtin").and_then(|v| v.as_bool()).unwrap_or(false);
let pattern_lower = pattern.to_lowercase();
let mut matches = Vec::new();
// Search custom indicators recursively
if let Some(indicators_dir) = &config.indicators_dir {
for entry in WalkDir::new(indicators_dir).max_depth(3) {
if let Ok(entry) = entry {
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(name) = path.file_stem() {
let name_str = name.to_string_lossy().to_string();
if name_str.to_lowercase().contains(&pattern_lower) {
let is_compiled = path.extension()
.map(|e| e == "ex5")
.unwrap_or(false);
matches.push(json!({
"name": name_str,
"path": path.to_string_lossy().to_string(),
"type": "custom",
"compiled": is_compiled,
}));
}
}
}
}
}
// Search built-in indicators if requested
if include_builtin {
let builtin = vec![
"Accelerator", "Accumulation", "ADX", "Alligator", "AO", "ATR",
"Bands", "Bears", "Bulls", "CCI", "DeMarker", "Envelopes", "Force",
"Fractals", "Gator", "Ichimoku", "MA", "MACD", "MFI", "Momentum",
"OBV", "OsMA", "RSI", "RVI", "SAR", "StdDev", "Stochastic", "WPR",
];
for name in builtin {
if name.to_lowercase().contains(&pattern_lower) {
matches.push(json!({
"name": name,
"path": null,
"type": "builtin",
"compiled": true,
}));
}
}
}
matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"pattern": pattern,
"count": matches.len(),
"matches": matches,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_search_scripts(config: &Config, args: &Value) -> Result<Value> {
let pattern = args.get("pattern")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("pattern is required"))?;
let pattern_lower = pattern.to_lowercase();
let mut matches = Vec::new();
if let Some(scripts_dir) = &config.scripts_dir {
for entry in WalkDir::new(scripts_dir).max_depth(3) {
if let Ok(entry) = entry {
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(name) = path.file_stem() {
let name_str = name.to_string_lossy().to_string();
if name_str.to_lowercase().contains(&pattern_lower) {
let is_compiled = path.extension()
.map(|e| e == "ex5")
.unwrap_or(false);
matches.push(json!({
"name": name_str,
"path": path.to_string_lossy().to_string(),
"compiled": is_compiled,
}));
}
}
}
}
}
matches.sort_by(|a, b| a["name"].as_str().cmp(&b["name"].as_str()));
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"pattern": pattern,
"count": matches.len(),
"matches": matches,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_copy_indicator_to_project(config: &Config, args: &Value) -> Result<Value> {
use std::path::PathBuf;
let source_path = args.get("source_path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("source_path is required"))?;
let target_name = args.get("target_name").and_then(|v| v.as_str());
// Determine project directory
let project_dir = config.project_dir.as_ref()
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.ok_or_else(|| anyhow::anyhow!("Cannot determine project directory"))?;
let source = PathBuf::from(source_path);
if !source.exists() {
return Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": false,
"error": format!("Source file not found: {}", source_path),
}).to_string() }],
"isError": true
}));
}
// Get extension
let ext = source.extension()
.and_then(|e| e.to_str())
.unwrap_or("mq5");
// Determine target name
let target_filename = match target_name {
Some(name) => format!("{}.{}", name, ext),
None => source.file_name()
.and_then(|n| n.to_str())
.map(|n| n.to_string())
.unwrap_or_else(|| format!("indicator.{}", ext)),
};
let destination = project_dir.join(&target_filename);
// Copy file
match std::fs::copy(&source, &destination) {
Ok(bytes) => {
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"source": source_path,
"destination": destination.to_string_lossy().to_string(),
"bytes_copied": bytes,
}).to_string() }],
"isError": false
}))
}
Err(e) => {
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": false,
"error": format!("Failed to copy file: {}", e),
}).to_string() }],
"isError": true
}))
}
}
copy_mql_to_project(config, args, "indicator")
}
pub async fn handle_copy_script_to_project(config: &Config, args: &Value) -> Result<Value> {
use std::path::PathBuf;
let source_path = args.get("source_path")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("source_path is required"))?;
let target_name = args.get("target_name").and_then(|v| v.as_str());
// Determine project directory
let project_dir = config.project_dir.as_ref()
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.ok_or_else(|| anyhow::anyhow!("Cannot determine project directory"))?;
let source = PathBuf::from(source_path);
if !source.exists() {
return Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": false,
"error": format!("Source file not found: {}", source_path),
}).to_string() }],
"isError": true
}));
}
// Get extension
let ext = source.extension()
.and_then(|e| e.to_str())
.unwrap_or("mq5");
// Determine target name
let target_filename = match target_name {
Some(name) => format!("{}.{}", name, ext),
None => source.file_name()
.and_then(|n| n.to_str())
.map(|n| n.to_string())
.unwrap_or_else(|| format!("script.{}", ext)),
};
let destination = project_dir.join(&target_filename);
// Copy file
match std::fs::copy(&source, &destination) {
Ok(bytes) => {
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"source": source_path,
"destination": destination.to_string_lossy().to_string(),
"bytes_copied": bytes,
}).to_string() }],
"isError": false
}))
}
Err(e) => {
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": false,
"error": format!("Failed to copy file: {}", e),
}).to_string() }],
"isError": true
}))
}
}
copy_mql_to_project(config, args, "script")
}
+88 -8
View File
@@ -2,8 +2,12 @@ use anyhow::Result;
use chrono::Datelike;
use serde_json::{json, Value};
use std::path::Path;
use std::sync::{Arc, OnceLock};
use std::sync::atomic::{AtomicBool, Ordering};
use crate::models::Config;
type NotificationCallback = Arc<dyn Fn(&str, serde_json::Value) + Send + Sync>;
mod system;
mod experts;
mod backtest;
@@ -13,23 +17,45 @@ mod setfiles;
mod reports;
mod utility;
#[derive(Debug)]
/// Cached result of the background update check.
/// - Not yet initialized: check still in flight (or not spawned yet)
/// - Some(version): a newer version is available
/// - None: already on latest, or GitHub unreachable
pub(crate) static LATEST_VERSION: OnceLock<Option<String>> = OnceLock::new();
static BACKGROUND_CHECK_SPAWNED: AtomicBool = AtomicBool::new(false);
#[derive(Clone)]
pub struct ToolHandler {
pub config: Config,
pub notification_callback: Option<NotificationCallback>,
}
impl ToolHandler {
pub fn new(config: Config) -> Self {
Self { config }
Self { config, notification_callback: None }
}
pub fn with_notification_callback(config: Config, callback: NotificationCallback) -> Self {
Self { config, notification_callback: Some(callback) }
}
pub async fn handle(&self, name: &str, args: &Value) -> Result<Value> {
// Spawn a one-shot background update check on the very first tool call of the session.
if !BACKGROUND_CHECK_SPAWNED.swap(true, Ordering::Relaxed) {
tokio::spawn(async {
let result = system::fetch_latest_version().await;
let _ = LATEST_VERSION.set(result);
});
}
match name {
// System handlers
"verify_setup" => system::handle_verify_setup(&self.config).await,
"list_symbols" => system::handle_list_symbols(&self.config).await,
"healthcheck" => system::handle_healthcheck(&self.config, args).await,
"verify_setup" => system::handle_verify_setup(&self.config).await,
"list_symbols" => system::handle_list_symbols(&self.config).await,
"healthcheck" => system::handle_healthcheck(&self.config, args).await,
"get_active_account" => system::handle_get_active_account(&self.config).await,
"check_update" => system::handle_check_update(&self.config).await,
"update" => system::handle_update(&self.config).await,
// Expert/Indicator/Script handlers
"list_experts" => experts::handle_list_experts(&self.config, args).await,
@@ -42,8 +68,11 @@ impl ToolHandler {
"copy_indicator_to_project" => experts::handle_copy_indicator_to_project(&self.config, args).await,
"copy_script_to_project" => experts::handle_copy_script_to_project(&self.config, args).await,
// Backtest handlers
"run_backtest" => backtest::handle_run_backtest(&self.config, args).await,
// Backtest handlers - Granular pipeline options
"run_backtest" => backtest::handle_run_backtest(&self.config, args).await, // Full: compile + clean + backtest + extract + analyze
"run_backtest_quick" => backtest::handle_run_backtest_quick(&self.config, args).await, // Quick: skip compile, do backtest + extract + analyze
"run_backtest_only" => backtest::handle_run_backtest_only(&self.config, args).await, // Minimal: skip compile, do backtest + extract only
"launch_backtest" => backtest::handle_launch_backtest(self, args).await, // Fire-and-forget mode
"get_backtest_status" => backtest::handle_get_backtest_status(&self.config, args).await,
"cache_status" => backtest::handle_cache_status(&self.config).await,
"clean_cache" => backtest::handle_clean_cache(&self.config, args).await,
@@ -65,6 +94,17 @@ impl ToolHandler {
"analyze_streaks" => analysis::handle_analyze_streaks(&self.config, args).await,
"analyze_concurrent_peak" => analysis::handle_analyze_concurrent_peak(&self.config, args).await,
"compare_baseline" => analysis::handle_compare_baseline(&self.config, args).await,
// Deal query handlers
"list_deals" => analysis::handle_list_deals(&self.config, args).await,
"search_deals_by_comment" => analysis::handle_search_deals_by_comment(&self.config, args).await,
"search_deals_by_magic" => analysis::handle_search_deals_by_magic(&self.config, args).await,
"analyze_profit_distribution" => analysis::handle_analyze_profit_distribution(&self.config, args).await,
"analyze_time_performance" => analysis::handle_analyze_time_performance(&self.config, args).await,
"analyze_hold_time_distribution" => analysis::handle_analyze_hold_time_distribution(&self.config, args).await,
"analyze_layer_performance" => analysis::handle_analyze_layer_performance(&self.config, args).await,
"analyze_volume_vs_profit" => analysis::handle_analyze_volume_vs_profit(&self.config, args).await,
"analyze_costs" => analysis::handle_analyze_costs(&self.config, args).await,
"analyze_efficiency" => analysis::handle_analyze_efficiency(&self.config, args).await,
// Set file handlers
"read_set_file" => setfiles::handle_read_set_file(args).await,
@@ -87,6 +127,15 @@ 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,
@@ -97,7 +146,17 @@ impl ToolHandler {
"check_mt5_status" => utility::handle_check_mt5_status(&self.config).await,
"create_set_template" => utility::handle_create_set_template(&self.config, args).await,
"export_report" => utility::handle_export_report(&self.config, args).await,
// Debugging/diagnostics handlers
"diagnose_wine" => utility::handle_diagnose_wine(&self.config, args).await,
"get_mt5_logs" => utility::handle_get_mt5_logs(&self.config, args).await,
"search_mt5_errors" => utility::handle_search_mt5_errors(&self.config, args).await,
"check_mt5_process" => utility::handle_check_mt5_process(&self.config, args).await,
"kill_mt5_process" => utility::handle_kill_mt5_process(&self.config, args).await,
"check_system_resources" => utility::handle_check_system_resources(&self.config, args).await,
"validate_mt5_config" => utility::handle_validate_mt5_config(&self.config, args).await,
"get_wine_prefix_info" => utility::handle_get_wine_prefix_info(&self.config, args).await,
"get_backtest_crash_info" => utility::handle_get_backtest_crash_info(&self.config, args).await,
_ => Ok(json!({
"content": [{ "type": "text", "text": format!("Tool '{}' not implemented", name) }],
"isError": true
@@ -133,3 +192,24 @@ pub(crate) fn past_complete_month() -> (String, String) {
last_of_prev.format("%Y.%m.%d").to_string(),
)
}
pub(crate) fn current_month() -> (String, String) {
let now = chrono::Utc::now();
let first_of_month = chrono::NaiveDate::from_ymd_opt(now.year(), now.month(), 1)
.unwrap_or_else(|| chrono::NaiveDate::from_ymd_opt(2024, 1, 1).unwrap());
let last_of_month = if now.month() == 12 {
chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1)
.unwrap_or(first_of_month)
.pred_opt()
.unwrap_or(first_of_month)
} else {
chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1)
.unwrap_or(first_of_month)
.pred_opt()
.unwrap_or(first_of_month)
};
(
first_of_month.format("%Y.%m.%d").to_string(),
last_of_month.format("%Y.%m.%d").to_string(),
)
}
+458
View File
@@ -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
}))
}
+172
View File
@@ -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, &current_exe)?;
Ok(ok_response(json!({
"success": true,
"previous_version": current,
"updated_to": latest,
"binary": current_exe.to_string_lossy(),
"hint": format!("Updated to v{latest}. Restart the MCP connection to load the new binary."),
})))
}
pub async fn handle_verify_setup(config: &Config) -> Result<Value> {
let mut checks = serde_json::Map::new();
let mut all_ok = true;
+875
View File
@@ -841,3 +841,878 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result<Valu
"isError": false
}))
}
// === Wine & MT5 Debugging Tools ===
/// Diagnose Wine installation and prefix health
pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Value> {
let mut diagnostics = json!({
"wine_executable": null,
"wine_version": null,
"wine_prefix": null,
"prefix_health": null,
"prefix_exists": false,
"prefix_size_mb": 0,
"errors": Vec::<String>::new(),
"warnings": Vec::<String>::new(),
});
// Check wine executable
if let Some(wine_exe) = config.wine_executable.as_ref() {
diagnostics["wine_executable"] = json!(wine_exe);
// Get Wine version
let version_output = std::process::Command::new(wine_exe)
.arg("--version")
.output();
match version_output {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
diagnostics["wine_version"] = json!(version);
}
_ => {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Failed to get Wine version - Wine may not be properly installed")
);
}
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Wine executable not configured")
);
}
// Check Wine prefix
if let Some(mt5_dir) = config.mt5_dir() {
let wine_prefix = mt5_dir
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent());
if let Some(prefix) = wine_prefix {
diagnostics["wine_prefix"] = json!(prefix.to_string_lossy().to_string());
// Check if prefix exists
let prefix_exists = prefix.exists();
diagnostics["prefix_exists"] = json!(prefix_exists);
if prefix_exists {
// Calculate prefix size
let mut total_size = 0u64;
fn calculate_size(dir: &Path, total: &mut u64) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Ok(meta) = entry.metadata() {
*total += meta.len();
}
} else if path.is_dir() {
calculate_size(&path, total);
}
}
}
}
calculate_size(prefix, &mut total_size);
diagnostics["prefix_size_mb"] = json!((total_size / 1024 / 1024) as i64);
// Check critical directories
let system32 = prefix.join("drive_c/windows/system32");
let program_files = prefix.join("drive_c/Program Files");
if !system32.exists() {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Wine prefix missing system32 directory - prefix may be corrupted")
);
diagnostics["prefix_health"] = json!("corrupted");
} else if !program_files.exists() {
diagnostics["warnings"].as_array_mut().unwrap().push(
json!("Program Files directory not found")
);
diagnostics["prefix_health"] = json!("incomplete");
} else {
diagnostics["prefix_health"] = json!("healthy");
}
// Check for recent Wine errors
let wine_log = prefix.join("wine.log");
if wine_log.exists() {
if let Ok(content) = fs::read_to_string(&wine_log) {
let recent_errors: Vec<&str> = content.lines()
.filter(|l| l.contains("err:") || l.contains("fixme:"))
.rev()
.take(10)
.collect();
if !recent_errors.is_empty() {
diagnostics["recent_wine_errors"] = json!(recent_errors);
}
}
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Wine prefix directory does not exist")
);
diagnostics["prefix_health"] = json!("missing");
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Could not determine Wine prefix from MT5 directory")
);
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("MT5 directory not configured")
);
}
let has_errors = !diagnostics["errors"].as_array().unwrap().is_empty();
Ok(json!({
"content": [{ "type": "text", "text": diagnostics.to_string() }],
"isError": has_errors
}))
}
/// Get MT5 terminal logs
pub async fn handle_get_mt5_logs(config: &Config, args: &Value) -> Result<Value> {
let log_type = args.get("log_type").and_then(|v| v.as_str()).unwrap_or("terminal");
let lines = args.get("lines").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
let search = args.get("search").and_then(|v| v.as_str());
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let log_path = match log_type {
"terminal" => mt5_dir.join("logs").join(format!("{}", chrono::Local::now().format("%Y%m%d"))),
"tester" => mt5_dir.join("Tester").join("logs"),
"metaeditor" => mt5_dir.join("MetaEditor").join("logs"),
_ => mt5_dir.join("logs"),
};
let mut result = json!({
"log_type": log_type,
"log_path": log_path.to_string_lossy().to_string(),
"found": false,
"lines_total": 0,
"lines_returned": 0,
"content": Vec::<String>::new(),
});
// Find log files
let mut log_files: Vec<_> = Vec::new();
if log_path.exists() {
if let Ok(entries) = fs::read_dir(&log_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "log" {
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
log_files.push((path, modified));
}
}
}
}
}
}
}
}
// Sort by modification time (newest first)
log_files.sort_by(|a, b| b.1.cmp(&a.1));
if let Some((latest_log, _)) = log_files.first() {
result["found"] = json!(true);
if let Ok(content) = fs::read_to_string(latest_log) {
let all_lines: Vec<&str> = content.lines().collect();
result["lines_total"] = json!(all_lines.len());
// Filter and limit lines
let mut filtered: Vec<&str> = all_lines.clone();
// Apply search filter
if let Some(search_term) = search {
let search_lower = search_term.to_lowercase();
filtered.retain(|line| line.to_lowercase().contains(&search_lower));
}
// Get last N lines
let start = filtered.len().saturating_sub(lines);
let final_lines: Vec<String> = filtered[start..].iter().map(|s| s.to_string()).collect();
result["lines_returned"] = json!(final_lines.len());
result["content"] = json!(final_lines);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Search MT5 logs for error patterns
pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result<Value> {
let error_patterns = vec![
"error", "failed", "crash", "exception", "access violation",
"out of memory", "cannot", "unable to", "terminated"
];
let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(24);
let max_errors = args.get("max_errors").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let mut errors_found = Vec::new();
let logs_dir = mt5_dir.join("logs");
let cutoff_time = std::time::SystemTime::now() - std::time::Duration::from_secs(hours_back * 3600);
// Search recent log files
if logs_dir.exists() {
if let Ok(entries) = fs::read_dir(&logs_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "log").unwrap_or(false) {
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
if modified >= cutoff_time {
if let Ok(content) = fs::read_to_string(&path) {
for (i, line) in content.lines().enumerate() {
let line_lower = line.to_lowercase();
for pattern in &error_patterns {
if line_lower.contains(pattern) {
errors_found.push(json!({
"file": path.file_name().unwrap_or_default().to_string_lossy().to_string(),
"line": i + 1,
"content": line.trim().to_string(),
"pattern": pattern,
}));
if errors_found.len() >= max_errors {
break;
}
}
}
}
}
}
}
}
}
if errors_found.len() >= max_errors {
break;
}
}
}
}
let result = json!({
"hours_searched": hours_back,
"errors_found": errors_found.len(),
"max_errors": max_errors,
"errors": errors_found,
"suggestion": if errors_found.is_empty() {
"No errors found in recent logs. Check get_mt5_logs for full log content."
} else {
"Found potential errors. Review the 'content' field for details."
},
});
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Check MT5 process status
pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result<Value> {
use std::process::Command;
let mut result = json!({
"is_running": false,
"processes": Vec::<serde_json::Value>::new(),
"wine_server_running": false,
"total_instances": 0,
});
#[cfg(target_os = "macos")]
{
// Check for MT5 processes
let ps_output = Command::new("ps")
.args(["aux"])
.output();
if let Ok(output) = ps_output {
let content = String::from_utf8_lossy(&output.stdout);
let mut processes = Vec::new();
let mut mt5_count = 0;
let mut wine_server = false;
for line in content.lines() {
let line_lower = line.to_lowercase();
if line_lower.contains("terminal64") || line_lower.contains("metatrader") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 11 {
processes.push(json!({
"pid": parts[1],
"cpu": parts[2],
"mem": parts[3],
"command": parts[10..].join(" "),
}));
mt5_count += 1;
}
}
if line_lower.contains("wineserver") {
wine_server = true;
}
}
result["processes"] = json!(processes);
result["is_running"] = json!(mt5_count > 0);
result["total_instances"] = json!(mt5_count);
result["wine_server_running"] = json!(wine_server);
}
}
#[cfg(target_os = "linux")]
{
let ps_output = Command::new("ps")
.args(["aux"])
.output();
if let Ok(output) = ps_output {
let content = String::from_utf8_lossy(&output.stdout);
let mut processes = Vec::new();
let mut mt5_count = 0;
let mut wine_server = false;
for line in content.lines() {
let line_lower = line.to_lowercase();
if line_lower.contains("terminal64") || line_lower.contains("metatrader") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 11 {
processes.push(json!({
"pid": parts[1],
"cpu": parts[2],
"mem": parts[3],
"command": parts[10..].join(" "),
}));
mt5_count += 1;
}
}
if line_lower.contains("wineserver") {
wine_server = true;
}
}
result["processes"] = json!(processes);
result["is_running"] = json!(mt5_count > 0);
result["total_instances"] = json!(mt5_count);
result["wine_server_running"] = json!(wine_server);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Kill stuck MT5 process
pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<Value> {
use std::process::Command;
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
let pid = args.get("pid").and_then(|v| v.as_str());
let mut killed = Vec::new();
let mut failed = Vec::new();
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
// Get list of MT5 processes
let ps_output = Command::new("ps")
.args(["aux"])
.output();
if let Ok(output) = ps_output {
let content = String::from_utf8_lossy(&output.stdout);
for line in content.lines() {
let line_lower = line.to_lowercase();
let should_kill = if let Some(target_pid) = pid {
line.contains(target_pid) && (line_lower.contains("terminal64") || line_lower.contains("metatrader"))
} else {
line_lower.contains("terminal64") || line_lower.contains("metatrader")
};
if should_kill {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let process_pid = parts[1];
let signal = if force { "-9" } else { "-15" };
match Command::new("kill").args([signal, process_pid]).output() {
Ok(_) => killed.push(process_pid.to_string()),
Err(e) => failed.push(format!("{}: {}", process_pid, e)),
}
}
}
}
}
// Also kill wineserver if force=true
if force {
let _ = Command::new("killall").arg("wineserver").output();
}
}
let message = if killed.is_empty() {
"No MT5 processes found to kill".to_string()
} else {
format!("Killed {} MT5 process(es)", killed.len())
};
let result = json!({
"killed": killed,
"failed": failed,
"force": force,
"message": message,
});
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": !failed.is_empty()
}))
}
/// Check system resources for MT5
pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> Result<Value> {
use std::process::Command;
let mut result = json!({
"disk_space": null,
"memory": null,
"cpu_cores": 0,
"recommendations": Vec::<String>::new(),
});
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
// Check disk space
let df_output = Command::new("df")
.args(["-h", "/"])
.output();
if let Ok(output) = df_output {
let content = String::from_utf8_lossy(&output.stdout);
for line in content.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 5 {
result["disk_space"] = json!({
"filesystem": parts[0],
"size": parts[1],
"used": parts[2],
"available": parts[3],
"use_percent": parts[4],
});
// Check if low on space
let use_pct = parts[4].trim_end_matches('%').parse::<u32>().unwrap_or(0);
if use_pct > 90 {
result["recommendations"].as_array_mut().unwrap().push(
json!("Disk space critically low. Clean MT5 cache with clean_cache.")
);
} else if use_pct > 80 {
result["recommendations"].as_array_mut().unwrap().push(
json!("Disk space getting low. Consider cleaning cache.")
);
}
}
}
}
// Check memory
#[cfg(target_os = "macos")]
{
let vm_output = Command::new("vm_stat").output();
if let Ok(output) = vm_output {
let content = String::from_utf8_lossy(&output.stdout);
// Parse vm_stat output
let mut free_pages = 0u64;
let mut active_pages = 0u64;
let mut inactive_pages = 0u64;
for line in content.lines() {
if line.contains("Pages free:") {
free_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
} else if line.contains("Pages active:") {
active_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
} else if line.contains("Pages inactive:") {
inactive_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
}
}
let page_size = 4096u64;
let total_mb = ((free_pages + active_pages + inactive_pages) * page_size) / 1024 / 1024;
let free_mb = (free_pages * page_size) / 1024 / 1024;
result["memory"] = json!({
"total_mb": total_mb,
"free_mb": free_mb,
"unit": "MB",
});
if free_mb < 2048 {
result["recommendations"].as_array_mut().unwrap().push(
json!("Low memory available. MT5 may crash during large optimizations.")
);
}
}
}
#[cfg(target_os = "linux")]
{
let mem_output = Command::new("free").args(["-m"]).output();
if let Ok(output) = mem_output {
let content = String::from_utf8_lossy(&output.stdout);
for line in content.lines() {
if line.starts_with("Mem:") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
result["memory"] = json!({
"total_mb": parts[1].parse::<u64>().unwrap_or(0),
"used_mb": parts[2].parse::<u64>().unwrap_or(0),
"free_mb": parts[3].parse::<u64>().unwrap_or(0),
"unit": "MB",
});
}
}
}
}
}
// Get CPU cores
let nproc_output = Command::new("sysctl")
.args(["-n", "hw.ncpu"])
.output();
if let Ok(output) = nproc_output {
let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0);
result["cpu_cores"] = json!(cores);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Validate MT5 configuration files
pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Result<Value> {
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let mut result = json!({
"terminal_ini": null,
"tester_ini": null,
"config_files_found": Vec::<String>::new(),
"errors": Vec::<String>::new(),
"warnings": Vec::<String>::new(),
});
// Check terminal.ini
let terminal_ini = mt5_dir.join("terminal.ini");
if terminal_ini.exists() {
result["config_files_found"].as_array_mut().unwrap().push(json!("terminal.ini"));
if let Ok(content) = fs::read_to_string(&terminal_ini) {
// Check for common issues
if !content.contains("[Common]") {
result["errors"].as_array_mut().unwrap().push(
json!("terminal.ini missing [Common] section")
);
}
// Extract key settings
let mut settings = serde_json::Map::new();
for line in content.lines() {
if line.starts_with("Login=") {
settings.insert("login".to_string(), json!(line.trim_start_matches("Login=")));
} else if line.starts_with("Server=") {
settings.insert("server".to_string(), json!(line.trim_start_matches("Server=")));
} else if line.starts_with("Expert=") {
settings.insert("expert".to_string(), json!(line.trim_start_matches("Expert=")));
}
}
result["terminal_ini"] = json!(settings);
} else {
result["errors"].as_array_mut().unwrap().push(
json!("Could not read terminal.ini")
);
}
} else {
result["warnings"].as_array_mut().unwrap().push(
json!("terminal.ini not found")
);
}
// Check for tester config
let tester_dir = mt5_dir.join("Tester");
if tester_dir.exists() {
if let Ok(entries) = fs::read_dir(&tester_dir) {
let ini_files: Vec<String> = entries
.flatten()
.filter_map(|e| {
let p = e.path();
if p.extension()?.to_str()? == "ini" {
Some(p.file_name()?.to_string_lossy().to_string())
} else {
None
}
})
.collect();
if !ini_files.is_empty() {
result["tester_ini"] = json!(ini_files);
}
}
}
// Check for common problems
let experts_dir = mt5_dir.join("MQL5").join("Experts");
if !experts_dir.exists() {
result["errors"].as_array_mut().unwrap().push(
json!("MQL5/Experts directory not found - MT5 installation may be incomplete")
);
}
let has_errors = !result["errors"].as_array().unwrap().is_empty();
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": has_errors
}))
}
/// Get Wine prefix detailed information
pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Result<Value> {
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let wine_prefix = mt5_dir
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent());
let mut result = json!({
"prefix_path": null,
"exists": false,
"windows_version": null,
"dll_overrides": Vec::<String>::new(),
"installed_programs": Vec::<String>::new(),
"registry_files": Vec::<String>::new(),
"drive_c_size_mb": 0,
});
if let Some(prefix) = wine_prefix {
result["prefix_path"] = json!(prefix.to_string_lossy().to_string());
result["exists"] = json!(prefix.exists());
if prefix.exists() {
// Check Windows version
let system_reg = prefix.join("system.reg");
if system_reg.exists() {
if let Ok(content) = fs::read_to_string(&system_reg) {
for line in content.lines().take(50) {
if line.contains("\"ProductName\"") {
let parts: Vec<&str> = line.split('"').collect();
if parts.len() >= 4 {
result["windows_version"] = json!(parts[3]);
}
}
}
}
}
// Calculate drive_c size
let drive_c = prefix.join("drive_c");
if drive_c.exists() {
let mut size = 0u64;
fn calc_size(dir: &Path, total: &mut u64) {
if let Ok(entries) = fs::read_dir(dir) {
for e in entries.flatten() {
let p = e.path();
if p.is_file() {
if let Ok(m) = e.metadata() {
*total += m.len();
}
} else if p.is_dir() {
calc_size(&p, total);
}
}
}
}
calc_size(&drive_c, &mut size);
result["drive_c_size_mb"] = json!((size / 1024 / 1024) as i64);
}
// Check for installed programs
let prog_files = prefix.join("drive_c").join("Program Files");
if prog_files.exists() {
if let Ok(entries) = fs::read_dir(&prog_files) {
let programs: Vec<String> = entries
.flatten()
.filter_map(|e| {
let p = e.path();
if p.is_dir() {
Some(p.file_name()?.to_string_lossy().to_string())
} else {
None
}
})
.collect();
result["installed_programs"] = json!(programs);
}
}
// List registry files
let reg_files: Vec<String> = vec![
"system.reg", "user.reg", "userdef.reg"
]
.into_iter()
.filter(|f| prefix.join(f).exists())
.map(|f| f.to_string())
.collect();
result["registry_files"] = json!(reg_files);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Get backtest crash/failure information
pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir").and_then(|v| v.as_str());
let check_recent = args.get("check_recent").and_then(|v| v.as_bool()).unwrap_or(true);
let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(6);
let mut result = json!({
"crashes_found": Vec::<serde_json::Value>::new(),
"recent_failures": 0,
"common_patterns": Vec::<String>::new(),
});
// Check specific report directory if provided
if let Some(dir) = report_dir {
let path = Path::new(dir);
if path.exists() {
// Check for incomplete markers
let incomplete_marker = path.join(".incomplete");
let error_log = path.join("error.log");
if incomplete_marker.exists() {
result["crashes_found"].as_array_mut().unwrap().push(json!({
"report_dir": dir,
"type": "incomplete",
"reason": "Backtest was interrupted or timed out",
}));
}
if error_log.exists() {
if let Ok(content) = fs::read_to_string(&error_log) {
result["crashes_found"].as_array_mut().unwrap().push(json!({
"report_dir": dir,
"type": "error_log",
"content": content.lines().take(20).collect::<Vec<_>>().join("\n"),
}));
}
}
// Check if deals.csv is missing or empty
let deals_csv = path.join("deals.csv");
if !deals_csv.exists() {
result["crashes_found"].as_array_mut().unwrap().push(json!({
"report_dir": dir,
"type": "missing_deals",
"reason": "deals.csv not found - backtest likely failed",
}));
} else if let Ok(meta) = deals_csv.metadata() {
if meta.len() < 100 {
result["crashes_found"].as_array_mut().unwrap().push(json!({
"report_dir": dir,
"type": "empty_deals",
"reason": "deals.csv is nearly empty - no trades were made",
}));
}
}
}
}
// Check recent reports if requested
if check_recent {
let reports_dir_str = config.get("reports_dir");
let reports_dir = Path::new(&reports_dir_str);
if reports_dir.exists() {
let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(hours_back * 3600);
if let Ok(entries) = fs::read_dir(&reports_dir) {
let mut failures = 0;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
if modified >= cutoff {
// Check for failure indicators
let has_deals = path.join("deals.csv").exists();
let has_incomplete = path.join(".incomplete").exists();
if !has_deals || has_incomplete {
failures += 1;
}
}
}
}
}
}
result["recent_failures"] = json!(failures);
}
}
}
// Analyze common patterns
let crashes = result["crashes_found"].as_array().unwrap();
if !crashes.is_empty() {
let types: Vec<String> = crashes.iter()
.filter_map(|c| c.get("type").and_then(|v| v.as_str()).map(|s| s.to_string()))
.collect();
if types.contains(&"missing_deals".to_string()) {
result["common_patterns"].as_array_mut().unwrap().push(
json!("Missing deals.csv suggests MT5 crashed during backtest")
);
}
if types.contains(&"incomplete".to_string()) {
result["common_patterns"].as_array_mut().unwrap().push(
json!("Incomplete markers indicate interruptions - check system resources")
);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
+193
View File
@@ -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()