feat: complete Rust migration with modular architecture
Major changes: - Migrate Python/shell scripts to Rust modules: - analytics/extract.py → src/analytics/extract.rs (ReportExtractor) - analytics/analyze.py → src/analytics/analyze.rs (DealAnalyzer) - scripts/mqlcompile.sh → src/compile/mql_compiler.rs (MqlCompiler) - scripts/backtest_pipeline.sh → src/pipeline/backtest.rs (BacktestPipeline) - New modular structure: - src/models/ - Config, Deal, Metrics, Report structs - src/analytics/ - Report parsing and deal analysis - src/compile/ - MQL5 compilation via Wine - src/pipeline/ - 5-stage backtest orchestration - src/tools/ - 27 MCP tool definitions and handlers - Remove PyInstaller setup (now pure Rust) - Remove migrated shell scripts (backtest_pipeline.sh, mqlcompile.sh) - Add GitHub Actions CI/CD for macOS & Linux releases - Update all documentation for Rust architecture Binary size: 4.3MB (no Python dependencies) Tools: 27 MCP tools fully functional
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release
|
||||
|
||||
- name: Package binary
|
||||
run: |
|
||||
mkdir -p dist/mt5-quant-macos
|
||||
cp target/release/mt5-quant dist/mt5-quant-macos/
|
||||
cp -r config dist/mt5-quant-macos/
|
||||
cp README.md dist/mt5-quant-macos/
|
||||
cp WINDSURF_SETUP.md dist/mt5-quant-macos/
|
||||
cd dist
|
||||
tar -czf mt5-quant-macos-arm64.tar.gz mt5-quant-macos
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-binary
|
||||
path: dist/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: Build release binary
|
||||
run: cargo build --release
|
||||
|
||||
- name: Package binary
|
||||
run: |
|
||||
mkdir -p dist/mt5-quant-linux
|
||||
cp target/release/mt5-quant dist/mt5-quant-linux/
|
||||
cp -r config dist/mt5-quant-linux/
|
||||
cp README.md dist/mt5-quant-linux/
|
||||
cp WINDSURF_SETUP.md dist/mt5-quant-linux/
|
||||
cd dist
|
||||
tar -czf mt5-quant-linux-x64.tar.gz mt5-quant-linux
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-binary
|
||||
path: dist/mt5-quant-linux-x64.tar.gz
|
||||
|
||||
release:
|
||||
needs: [build-macos, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
permissions:
|
||||
contents: write
|
||||
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 Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
dist/mt5-quant-macos-arm64.tar.gz
|
||||
dist/mt5-quant-linux-x64.tar.gz
|
||||
draft: false
|
||||
prerelease: false
|
||||
generate_release_notes: true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
Generated
+49
@@ -203,6 +203,15 @@ dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -219,6 +228,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -367,6 +382,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -407,10 +428,12 @@ dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"dirs",
|
||||
"encoding_rs",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -566,6 +589,19 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -713,6 +749,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
|
||||
@@ -23,3 +23,5 @@ regex = "1.0"
|
||||
dirs = "5.0"
|
||||
walkdir = "2.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
encoding_rs = "0.8"
|
||||
tempfile = "3.0"
|
||||
|
||||
@@ -58,16 +58,31 @@ The AI drives every step. You watch and approve.
|
||||
|
||||
## Quickstart
|
||||
|
||||
### 1. Clone and install
|
||||
### Option 1: Download Prebuilt Binary (Recommended)
|
||||
|
||||
```bash
|
||||
# macOS (Apple Silicon)
|
||||
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
|
||||
tar -xzf mt5-quant.tar.gz
|
||||
cd mt5-quant-macos-arm64
|
||||
./mt5-quant --help
|
||||
|
||||
# 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
|
||||
cd mt5-quant-linux-x64
|
||||
./mt5-quant --help
|
||||
```
|
||||
|
||||
### Option 2: Build from Source
|
||||
|
||||
```bash
|
||||
git clone https://github.com/masdevid/mt5-mcp
|
||||
cd mt5-mcp
|
||||
python3 -m venv .venv && source .venv/bin/activate
|
||||
pip install -e .
|
||||
bash scripts/build-rust.sh
|
||||
```
|
||||
|
||||
> **Python 3.11+** required. The venv is optional but recommended.
|
||||
> **Rust** required. Install from [rustup.rs](https://rustup.rs/)
|
||||
|
||||
### 2. Install MetaTrader 5
|
||||
|
||||
@@ -146,10 +161,7 @@ terminal_dir: "~/Library/Application Support/net.metaquotes.wine.metatrader5/dri
|
||||
|
||||
```bash
|
||||
# Add to Claude Code (adjust path to where you cloned the repo)
|
||||
claude mcp add MT5-Quant -- python3 /path/to/mt5-quant/server/main.py
|
||||
|
||||
# Or with the venv python explicitly:
|
||||
claude mcp add MT5-Quant -- /path/to/mt5-quant/.venv/bin/python3 /path/to/mt5-quant/server/main.py
|
||||
claude mcp add MT5-Quant -- /path/to/mt5-quant/target/release/mt5-quant
|
||||
```
|
||||
|
||||
`setup.sh` runs this automatically. To check registration:
|
||||
@@ -160,7 +172,7 @@ claude mcp list
|
||||
|
||||
Expected output:
|
||||
```
|
||||
MT5-Quant: python3 /path/to/mt5-quant/server/main.py
|
||||
MT5-Quant: /path/to/mt5-quant/target/release/mt5-quant
|
||||
```
|
||||
|
||||
**Claude Code integration files** (CLAUDE.md template + baseline hook):
|
||||
@@ -297,10 +309,10 @@ Full schema: [docs/MCP_TOOLS.md](docs/MCP_TOOLS.md)
|
||||
```
|
||||
AI Agent (Claude / Cursor)
|
||||
│ MCP protocol (stdio)
|
||||
MT5-Quant server (Python)
|
||||
MT5-Quant server (Rust)
|
||||
│ subprocess
|
||||
Pipeline scripts (bash)
|
||||
│ Wine/CrossOver
|
||||
Wine/CrossOver
|
||||
│
|
||||
MetaTrader 5 (Windows/Wine)
|
||||
│
|
||||
analysis.json ← AI reads this
|
||||
@@ -490,7 +502,7 @@ bash scripts/setup.sh --yes
|
||||
```bash
|
||||
claude mcp list # should show MT5-Quant
|
||||
claude mcp remove MT5-Quant # remove stale entry if needed
|
||||
claude mcp add MT5-Quant -- python3 /absolute/path/to/mt5-quant/server/main.py
|
||||
claude mcp add MT5-Quant -- /absolute/path/to/mt5-quant/target/release/mt5-quant
|
||||
```
|
||||
|
||||
Use an **absolute path** — relative paths break when Claude starts from a different working directory.
|
||||
|
||||
+24
-18
@@ -2,9 +2,22 @@
|
||||
|
||||
## Quick Setup
|
||||
|
||||
### 1. Build Executable
|
||||
### Option 1: Download Prebuilt Binary (Recommended)
|
||||
|
||||
```bash
|
||||
bash scripts/build-executable-onedir.sh
|
||||
# macOS (Apple Silicon)
|
||||
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
|
||||
tar -xzf mt5-quant.tar.gz
|
||||
|
||||
# Linux (x64)
|
||||
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
|
||||
tar -xzf mt5-quant.tar.gz
|
||||
```
|
||||
|
||||
### Option 2: Build from Source
|
||||
|
||||
```bash
|
||||
bash scripts/build-rust.sh
|
||||
```
|
||||
|
||||
### 2. Configure Windsurf
|
||||
@@ -14,23 +27,16 @@ Edit `~/.windsurf/config.yaml`:
|
||||
```yaml
|
||||
mcpServers:
|
||||
mt5-quant:
|
||||
command: /Users/masdevid/jobs/mt5-mcp/dist/mt5-quant/mt5-quant
|
||||
```
|
||||
|
||||
Or dengan environment variable:
|
||||
```yaml
|
||||
mcpServers:
|
||||
mt5-quant:
|
||||
command: /Users/masdevid/jobs/mt5-mcp/dist/mt5-quant/mt5-quant
|
||||
command: /Users/masdevid/jobs/mt5-quant/target/release/mt5-quant
|
||||
env:
|
||||
MT5_MCP_HOME: /Users/masdevid/jobs/mt5-mcp
|
||||
MT5_MCP_HOME: /Users/masdevid/jobs/mt5-quant
|
||||
```
|
||||
|
||||
### 3. Restart Windsurf
|
||||
Close dan reopen Windsurf untuk load MCP server.
|
||||
Close and reopen Windsurf to load the MCP server.
|
||||
|
||||
### 4. Verify
|
||||
Di Windsurf chat, test dengan:
|
||||
In Windsurf chat, test with:
|
||||
```
|
||||
Run verify_setup
|
||||
```
|
||||
@@ -39,16 +45,16 @@ Run verify_setup
|
||||
|
||||
### Build untuk Distribution
|
||||
```bash
|
||||
# Build
|
||||
bash scripts/build-executable-onedir.sh
|
||||
# Build release binary
|
||||
cargo build --release
|
||||
|
||||
# Create tarball
|
||||
tar -czf mt5-quant-macos-arm64.tar.gz -C dist mt5-quant
|
||||
tar -czf mt5-quant-macos-arm64.tar.gz -C target/release mt5-quant
|
||||
|
||||
# Deploy ke remote server
|
||||
scp mt5-quant-macos-arm64.tar.gz user@server:~/
|
||||
ssh user@server "tar -xzf mt5-quant-macos-arm64.tar.gz -C /opt/"
|
||||
ssh user@server "ln -s /opt/mt5-quant/mt5-quant /usr/local/bin/"
|
||||
ssh user@server "ln -s /opt/mt5-quant /usr/local/bin/"
|
||||
|
||||
# Copy config
|
||||
scp -r config/mt5-quant.yaml user@server:~/.config/mt5-quant/config/
|
||||
@@ -71,7 +77,7 @@ mcpServers:
|
||||
### MCP server not appearing
|
||||
1. Check Windsurf logs: `~/.windsurf/logs/`
|
||||
2. Verify executable path is absolute
|
||||
3. Test executable manually: `./dist/mt5-quant/mt5-quant --help`
|
||||
3. Test executable manually: `./target/release/mt5-quant --help`
|
||||
|
||||
### Config not found
|
||||
Set `MT5_MCP_HOME` environment variable atau pastikan config di default location:
|
||||
|
||||
+70
-60
@@ -16,36 +16,45 @@ MT5-Quant extracts every individual deal — entry price, exit price, P/L, comme
|
||||
|
||||
```
|
||||
MT5-Quant/
|
||||
├── server/
|
||||
│ └── main.py # MCP server (stdio transport) — 10 tool handlers
|
||||
├── src/
|
||||
│ ├── main.rs # MCP server entry (stdio transport)
|
||||
│ ├── mcp_server.rs # MCP protocol handling
|
||||
│ ├── models/ # Data structures
|
||||
│ │ ├── config.rs # Configuration
|
||||
│ │ ├── deals.rs # Deal, PositionPair, DrawdownEvent, etc.
|
||||
│ │ ├── 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
|
||||
│ │ └── 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
|
||||
│ └── tools/ # MCP tool definitions
|
||||
│ ├── definitions.rs # 27 tool schemas
|
||||
│ └── handlers.rs # Tool dispatch
|
||||
│
|
||||
├── scripts/
|
||||
│ ├── setup.sh # Auto-detect Wine/MT5, write config, register MCP
|
||||
│ ├── platform_detect.sh # Sourced by all scripts — Wine path + headless detection
|
||||
│ ├── backtest_pipeline.sh # 5-stage pipeline orchestrator
|
||||
│ ├── optimize.sh # Genetic optimization launcher (nohup + disown)
|
||||
│ └── mqlcompile.sh # MetaEditor wrapper (Wine/CrossOver)
|
||||
│ ├── setup.sh # Auto-detect Wine/MT5, write config, register MCP
|
||||
│ ├── platform_detect.sh # Wine path + headless detection
|
||||
│ ├── build-rust.sh # Rust build script
|
||||
│ └── optimize.sh # Genetic optimization launcher (nohup + disown)
|
||||
│
|
||||
├── analytics/
|
||||
│ ├── extract.py # HTML/XML report parser → metrics.json + deals.csv
|
||||
│ ├── analyze.py # Deal-level analysis engine → analysis.json
|
||||
│ └── optimize_parser.py # Optimization result parser
|
||||
├── analytics/ # Legacy Python (reference only)
|
||||
│ ├── extract.py
|
||||
│ ├── analyze.py
|
||||
│ └── optimize_parser.py
|
||||
│
|
||||
├── config/
|
||||
│ ├── MT5-Quant.example.yaml # Template config (copy to MT5-Quant.yaml)
|
||||
│ ├── MT5-Quant.yaml # Live config (gitignored)
|
||||
│ ├── baseline.json # Production baseline metrics (gitignored, user-maintained)
|
||||
│ ├── CLAUDE.template.md # CLAUDE.md template (generated by --claude-code)
|
||||
│ └── example.set # Example optimization .set file
|
||||
│
|
||||
├── .claude/
|
||||
│ └── hooks/
|
||||
│ └── user-prompt-submit.sh # Injects baseline.json into every Claude prompt
|
||||
│ ├── mt5-quant.example.yaml # Template config
|
||||
│ └── mt5-quant.yaml # Live config (gitignored)
|
||||
│
|
||||
└── docs/
|
||||
├── ARCHITECTURE.md # This file
|
||||
├── MCP_TOOLS.md # Full tool spec
|
||||
└── REMOTE_AGENTS.md # Linux agent farm setup
|
||||
├── ARCHITECTURE.md # This file
|
||||
├── MCP_TOOLS.md # Full tool spec
|
||||
└── REMOTE_AGENTS.md # Linux agent farm setup
|
||||
```
|
||||
|
||||
---
|
||||
@@ -54,11 +63,13 @@ MT5-Quant/
|
||||
|
||||
### Stage 1: COMPILE
|
||||
|
||||
```bash
|
||||
./scripts/mqlcompile.sh src/experts/MyEA.mq5
|
||||
```rust
|
||||
// src/compile/mql_compiler.rs
|
||||
let compiler = MqlCompiler::new(config);
|
||||
let result = compiler.compile("src/experts/MyEA.mq5")?;
|
||||
```
|
||||
|
||||
Invokes MetaEditor via Wine with the MQL5 source file. Copies resulting `.ex5` to the MT5 Experts directory. Fails the pipeline on compile errors (stderr contains error count).
|
||||
Invokes MetaEditor via Wine with the MQL5 source file. Copies resulting `.ex5` to the MT5 Experts directory. Fails the pipeline on compile errors.
|
||||
|
||||
**Why not skip this?** MT5 caches the `.ex5` binary by filename. If you edit your EA and re-run without recompiling, MT5 runs the old binary silently. Always compile.
|
||||
|
||||
@@ -128,29 +139,30 @@ MT5 runs in headless mode, writes the report, and exits.
|
||||
|
||||
Single HTML/XML parse pass that produces three artifacts:
|
||||
|
||||
```bash
|
||||
python3 analytics/extract.py report.htm
|
||||
# → metrics.json (aggregate summary)
|
||||
# → deals.csv (all deals, 13 columns)
|
||||
# → deals.json (same data, JSON)
|
||||
```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)
|
||||
```
|
||||
|
||||
**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, one per artifact. Collapsed to one Python pass: 5× faster and no partial-read inconsistencies.
|
||||
**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.
|
||||
|
||||
**Format detection:**
|
||||
```python
|
||||
# MT5 Build 48+ saves SpreadsheetML XML, not HTML
|
||||
if report_path.endswith('.xml') or report_path.endswith('.htm.xml'):
|
||||
tree = ET.parse(report_path)
|
||||
# parse via ElementTree
|
||||
else:
|
||||
with open(report_path, 'rb') as f:
|
||||
raw = f.read()
|
||||
try:
|
||||
text = raw.decode('utf-16')
|
||||
except:
|
||||
text = raw.decode('latin-1', errors='replace')
|
||||
# parse via regex
|
||||
```rust
|
||||
// MT5 Build 48+ saves SpreadsheetML XML, not HTML
|
||||
let ext = Path::new(&path).extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if ext == "xml" || path.ends_with(".htm.xml") {
|
||||
// Parse as SpreadsheetML XML
|
||||
let doc = roxmltree::Document::parse(&text)?;
|
||||
} else {
|
||||
// Parse as HTML with regex
|
||||
}
|
||||
```
|
||||
|
||||
**Deal columns (13):**
|
||||
@@ -164,23 +176,21 @@ The `Comment` column is the key to grid analytics. The EA writes `"Layer #3"`, `
|
||||
|
||||
### Stage 5: ANALYZE
|
||||
|
||||
```bash
|
||||
# Strategy subcommand (recommended)
|
||||
python3 analytics/analyze.py grid deals.csv --output-dir reports/20250101/
|
||||
python3 analytics/analyze.py scalper deals.csv --output-dir reports/20250101/
|
||||
python3 analytics/analyze.py trend deals.csv --output-dir reports/20250101/
|
||||
python3 analytics/analyze.py hedge deals.csv --output-dir reports/20250101/
|
||||
python3 analytics/analyze.py generic deals.csv --output-dir reports/20250101/
|
||||
|
||||
# Legacy positional (no subcommand) — defaults to 'grid', backward compatible
|
||||
python3 analytics/analyze.py deals.csv --output-dir reports/20250101/
|
||||
|
||||
# Flags available with any strategy
|
||||
python3 analytics/analyze.py grid deals.csv --output-dir DIR --deep --stdout
|
||||
# → analysis.json
|
||||
```rust
|
||||
// src/analytics/analyze.rs
|
||||
let analyzer = DealAnalyzer::new();
|
||||
let result = analyzer.analyze(&deals, &metrics, strategy, deep)?;
|
||||
// → analysis.json
|
||||
```
|
||||
|
||||
All functions operate on the 13-column `deals.csv` — no MT5 or Wine required.
|
||||
All functions operate on the parsed deal data — no MT5 or Wine required.
|
||||
|
||||
**Strategy profiles** (defined in `analyze.rs`):
|
||||
- `grid` — Layer depth tracking, locking/cutloss/zombie keywords
|
||||
- `scalper` — TP/SL/manual/trailing exit classification
|
||||
- `trend` — TP/SL/trailing/breakeven/partial exits
|
||||
- `hedge` — TP/SL/net_close/partial, magic+direction grouping
|
||||
- `generic` — Simple profit-based TP/SL classification
|
||||
|
||||
#### Strategy profiles
|
||||
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""
|
||||
PyInstaller spec file for MT5-Quant MCP Server (onedir mode)
|
||||
This version creates a directory with the executable + dependencies
|
||||
Better for MCP stdio communication than onefile mode
|
||||
|
||||
Build:
|
||||
pyinstaller mt5-quant-onedir.spec
|
||||
|
||||
Output:
|
||||
dist/mt5-quant/ directory with executable inside
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
block_cipher = None
|
||||
|
||||
# Project root - the build script runs from project root, so use cwd
|
||||
root = Path(os.getcwd()).resolve()
|
||||
|
||||
# Collect entire mcp package (datas, binaries, hiddenimports)
|
||||
mcp_datas, mcp_binaries, mcp_hiddenimports = collect_all('mcp')
|
||||
|
||||
# Data files to include
|
||||
# Format: (source_path, dest_dir_in_bundle)
|
||||
datas = [
|
||||
# Include scripts directory (bash pipeline scripts)
|
||||
(str(root / 'scripts'), 'scripts'),
|
||||
# Include docs if needed
|
||||
(str(root / 'docs'), 'docs'),
|
||||
] + mcp_datas
|
||||
|
||||
# Hidden imports (modules that are imported dynamically or might be missed)
|
||||
hiddenimports = mcp_hiddenimports + [
|
||||
# MCP stdio - explicitly include for PyInstaller
|
||||
'mcp.server.stdio',
|
||||
'mcp.shared.memory',
|
||||
'mcp.shared.session',
|
||||
'mcp.server.models',
|
||||
# Analytics modules (local project modules)
|
||||
'analytics',
|
||||
'analytics.extract',
|
||||
'analytics.analyze',
|
||||
'analytics.optimize_parser',
|
||||
# Other dependencies
|
||||
'pydantic',
|
||||
'pydantic.v1',
|
||||
'pydantic.v1.fields',
|
||||
'pydantic.v1.main',
|
||||
'pydantic_core',
|
||||
'yaml',
|
||||
'_yaml',
|
||||
'yaml.constructor',
|
||||
'yaml.representer',
|
||||
'yaml.cyaml',
|
||||
'anyio',
|
||||
'anyio.streams',
|
||||
'anyio.streams.memory',
|
||||
'anyio.streams.text',
|
||||
# Other stdlib that might be dynamically imported
|
||||
'xml.etree.ElementTree',
|
||||
'html.parser',
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
['server/main.py'],
|
||||
pathex=[str(root), str(root / 'server'), str(root / 'analytics')],
|
||||
binaries=mcp_binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
# Exclude unnecessary packages to reduce binary size
|
||||
'matplotlib',
|
||||
'PIL',
|
||||
'numpy',
|
||||
'pandas',
|
||||
'scipy',
|
||||
'tkinter',
|
||||
'PyQt5',
|
||||
'PyQt6',
|
||||
'wx',
|
||||
'test',
|
||||
'unittest',
|
||||
'pydoc',
|
||||
'doctest',
|
||||
'email',
|
||||
'http.server',
|
||||
'ftplib',
|
||||
'telnetlib',
|
||||
'ssl',
|
||||
'sqlite3',
|
||||
],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
[],
|
||||
name='mt5-quant',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=True,
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
|
||||
# For onedir mode, we need to use COLLECT
|
||||
# But actually, on macOS/Linux, just having the EXE without onefile=True creates a directory
|
||||
# Wait, actually we need to check PyInstaller version behavior
|
||||
# Modern PyInstaller creates onedir by default for EXE + COLLECT
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""
|
||||
PyInstaller spec file for MT5-Quant MCP Server
|
||||
|
||||
Build:
|
||||
pyinstaller mt5-quant.spec
|
||||
|
||||
Output:
|
||||
dist/mt5-quant (single executable)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
block_cipher = None
|
||||
|
||||
# Project root - the build script runs from project root, so use cwd
|
||||
root = Path(os.getcwd()).resolve()
|
||||
|
||||
# Collect entire mcp package (datas, binaries, hiddenimports)
|
||||
mcp_datas, mcp_binaries, mcp_hiddenimports = collect_all('mcp')
|
||||
|
||||
# Data files to include
|
||||
# Format: (source_path, dest_dir_in_bundle)
|
||||
datas = [
|
||||
# Include scripts directory (bash pipeline scripts)
|
||||
(str(root / 'scripts'), 'scripts'),
|
||||
# Include docs if needed
|
||||
(str(root / 'docs'), 'docs'),
|
||||
] + mcp_datas
|
||||
|
||||
# Hidden imports (modules that are imported dynamically or might be missed)
|
||||
# Note: mcp_hiddenimports already includes all mcp submodules from collect_all()
|
||||
hiddenimports = mcp_hiddenimports + [
|
||||
# MCP package - all submodules
|
||||
'mcp',
|
||||
'mcp.types',
|
||||
'mcp.server',
|
||||
'mcp.server.stdio',
|
||||
'mcp.server.models',
|
||||
'mcp.server.session',
|
||||
'mcp.shared',
|
||||
'mcp.shared.memory',
|
||||
'mcp.shared.session',
|
||||
'mcp.shared.exceptions',
|
||||
'mcp.shared.context',
|
||||
'mcp.shared.progress',
|
||||
'mcp.shared.version',
|
||||
'mcp.client',
|
||||
'mcp.cli',
|
||||
# Analytics modules (local project modules)
|
||||
'analytics',
|
||||
'analytics.extract',
|
||||
'analytics.analyze',
|
||||
'analytics.optimize_parser',
|
||||
# Other dependencies
|
||||
'pydantic',
|
||||
'pydantic.v1',
|
||||
'pydantic.v1.fields',
|
||||
'pydantic.v1.main',
|
||||
'pydantic_core',
|
||||
'yaml',
|
||||
'_yaml',
|
||||
'yaml.constructor',
|
||||
'yaml.representer',
|
||||
'yaml.cyaml',
|
||||
'anyio',
|
||||
'anyio.streams',
|
||||
'anyio.streams.memory',
|
||||
'anyio.streams.text',
|
||||
'anyio._backends',
|
||||
'anyio._backends._asyncio',
|
||||
# Other stdlib that might be dynamically imported
|
||||
'xml.etree.ElementTree',
|
||||
'html.parser',
|
||||
'select',
|
||||
'ssl',
|
||||
'_ssl',
|
||||
'certifi',
|
||||
'email',
|
||||
'email.parser',
|
||||
'email.message',
|
||||
'importlib.metadata',
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
['server/main.py'],
|
||||
pathex=[str(root), str(root / 'server'), str(root / 'analytics')],
|
||||
binaries=mcp_binaries,
|
||||
datas=datas,
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[str(root / 'hooks')],
|
||||
hooksconfig={
|
||||
'mcp': {
|
||||
'include_all': True,
|
||||
}
|
||||
},
|
||||
runtime_hooks=[],
|
||||
excludes=[
|
||||
# Exclude unnecessary packages to reduce binary size
|
||||
'matplotlib',
|
||||
'PIL',
|
||||
'numpy',
|
||||
'pandas',
|
||||
'scipy',
|
||||
'tkinter',
|
||||
'PyQt5',
|
||||
'PyQt6',
|
||||
'wx',
|
||||
'test',
|
||||
'unittest',
|
||||
'pydoc',
|
||||
'doctest',
|
||||
# 'email', # Required by pydantic -> mcp
|
||||
# 'http.server',
|
||||
# 'ftplib',
|
||||
# 'telnetlib',
|
||||
# 'ssl', # Required by anyio -> mcp
|
||||
'sqlite3',
|
||||
],
|
||||
win_no_prefer_redirects=False,
|
||||
win_private_assemblies=False,
|
||||
cipher=block_cipher,
|
||||
noarchive=False,
|
||||
)
|
||||
|
||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
||||
|
||||
# Create the EXE without including binaries/datas (onedir mode)
|
||||
exe = EXE(
|
||||
pyz,
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name='mt5-quant',
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True,
|
||||
disable_windowed_traceback=False,
|
||||
target_arch=None,
|
||||
codesign_identity=None,
|
||||
entitlements_file=None,
|
||||
)
|
||||
|
||||
# Collect all files into directory (onedir mode)
|
||||
coll = COLLECT(
|
||||
exe,
|
||||
a.binaries,
|
||||
a.zipfiles,
|
||||
a.datas,
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
name='mt5-quant'
|
||||
)
|
||||
@@ -1,530 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# backtest_pipeline.sh — 5-stage MT5 backtest pipeline
|
||||
# Stages: COMPILE → CLEAN → BACKTEST → EXTRACT → ANALYZE
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/backtest_pipeline.sh [options]
|
||||
#
|
||||
# Options:
|
||||
# --expert NAME EA name (without path or .mq5 extension)
|
||||
# --symbol SYMBOL Trading symbol (default: from config)
|
||||
# --from YYYY.MM.DD Start date
|
||||
# --to YYYY.MM.DD End date
|
||||
# --preset PRESET last_month | last_3months | ytd | last_year
|
||||
# --timeframe TF M1 M5 M15 M30 H1 H4 D1 (default: M5)
|
||||
# --deposit AMOUNT Initial deposit (default: from config)
|
||||
# --model 0|1|2 Tick model (default: 0=every tick)
|
||||
# --set FILE Path to .set parameter file
|
||||
# --leverage N Leverage (default: 500)
|
||||
# --skip-compile Skip compilation stage
|
||||
# --skip-clean Skip cache clean stage
|
||||
# --skip-analyze Skip analysis stage (extract only)
|
||||
# --deep Run deep analysis (hourly + volume profile)
|
||||
# --strategy NAME Analysis strategy profile: grid (default) | scalper | trend | hedge | generic
|
||||
# --timeout N Backtest timeout in seconds (default: 900)
|
||||
# --shutdown Close MT5 after backtest (default: keep open). Use for CI/headless.
|
||||
# --kill-existing Kill running MT5 before launching. Required when MT5 is open.
|
||||
# With default mode (no --shutdown): MT5 restarts, runs backtest,
|
||||
# then stays open so you can inspect results in the GUI.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# Resolve real physical path (follows symlinks) so analytics/ is found even when
|
||||
# scripts/ is a symlink (e.g. ~/.config/mt5-quant/scripts -> /path/to/mt5-quant/scripts)
|
||||
REAL_SCRIPT_DIR="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(cd "${REAL_SCRIPT_DIR}/.." && pwd)"
|
||||
source "${SCRIPT_DIR}/platform_detect.sh"
|
||||
|
||||
# ── Defaults from config ──────────────────────────────────────────────────────
|
||||
DEFAULT_SYMBOL=$(_cfg "backtest_symbol" "XAUUSD")
|
||||
DEFAULT_DEPOSIT=$(_cfg "backtest_deposit" "10000")
|
||||
DEFAULT_CURRENCY=$(_cfg "backtest_currency" "USD")
|
||||
DEFAULT_LEVERAGE=$(_cfg "backtest_leverage" "500")
|
||||
DEFAULT_MODEL=$(_cfg "backtest_model" "0")
|
||||
DEFAULT_TF=$(_cfg "backtest_timeframe" "M5")
|
||||
DEFAULT_TIMEOUT=$(_cfg "backtest_timeout" "900")
|
||||
REPORTS_DIR="$(_cfg "reports_dir" "${ROOT_DIR}/reports")"
|
||||
# Optional: force headless terminal to a specific broker account (needed when live
|
||||
# trading terminal uses a different broker than the backtest symbol requires).
|
||||
DEFAULT_LOGIN=$(_cfg "backtest_login" "")
|
||||
DEFAULT_SERVER=$(_cfg "backtest_server" "")
|
||||
|
||||
# ── Parse arguments ───────────────────────────────────────────────────────────
|
||||
EXPERT=""
|
||||
SYMBOL="$DEFAULT_SYMBOL"
|
||||
FROM_DATE=""
|
||||
TO_DATE=""
|
||||
PRESET=""
|
||||
TIMEFRAME="$DEFAULT_TF"
|
||||
DEPOSIT="$DEFAULT_DEPOSIT"
|
||||
CURRENCY="$DEFAULT_CURRENCY"
|
||||
MODEL="$DEFAULT_MODEL"
|
||||
SET_FILE=""
|
||||
LEVERAGE="$DEFAULT_LEVERAGE"
|
||||
SKIP_COMPILE=false
|
||||
SKIP_CLEAN=false
|
||||
SKIP_ANALYZE=false
|
||||
DEEP_ANALYZE=false
|
||||
STRATEGY="grid"
|
||||
TIMEOUT="$DEFAULT_TIMEOUT"
|
||||
PROJECT_DIR="$(_cfg "project_dir" "")"
|
||||
GUI_MODE=false
|
||||
SHUTDOWN_TERMINAL=false
|
||||
KILL_EXISTING=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--expert) EXPERT="$2"; shift 2 ;;
|
||||
--project-dir) PROJECT_DIR="$2"; shift 2 ;;
|
||||
--gui) GUI_MODE=true; shift ;;
|
||||
--symbol) SYMBOL="$2"; shift 2 ;;
|
||||
--from) FROM_DATE="$2"; shift 2 ;;
|
||||
--to) TO_DATE="$2"; shift 2 ;;
|
||||
--preset) PRESET="$2"; shift 2 ;;
|
||||
--timeframe) TIMEFRAME="$2"; shift 2 ;;
|
||||
--deposit) DEPOSIT="$2"; shift 2 ;;
|
||||
--model) MODEL="$2"; shift 2 ;;
|
||||
--set) SET_FILE="$2"; shift 2 ;;
|
||||
--leverage) LEVERAGE="$2"; shift 2 ;;
|
||||
--timeout) TIMEOUT="$2"; shift 2 ;;
|
||||
--skip-compile) SKIP_COMPILE=true; shift ;;
|
||||
--skip-clean) SKIP_CLEAN=true; shift ;;
|
||||
--skip-analyze) SKIP_ANALYZE=true; shift ;;
|
||||
--deep) DEEP_ANALYZE=true; shift ;;
|
||||
--strategy) STRATEGY="$2"; shift 2 ;;
|
||||
--shutdown) SHUTDOWN_TERMINAL=true; shift ;;
|
||||
--kill-existing) KILL_EXISTING=true; shift ;;
|
||||
*) echo "Unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ -z "$EXPERT" ]] && { echo "ERROR: --expert is required" >&2; exit 1; }
|
||||
|
||||
# ── Preset date resolution ────────────────────────────────────────────────────
|
||||
if [[ -n "$PRESET" ]]; then
|
||||
TODAY=$(date +%Y.%m.%d)
|
||||
case "$PRESET" in
|
||||
last_month)
|
||||
FROM_DATE=$(date -d "1 month ago" +%Y.%m.01 2>/dev/null || \
|
||||
date -v-1m +%Y.%m.01)
|
||||
TO_DATE="$TODAY"
|
||||
;;
|
||||
last_3months)
|
||||
FROM_DATE=$(date -d "3 months ago" +%Y.%m.01 2>/dev/null || \
|
||||
date -v-3m +%Y.%m.01)
|
||||
TO_DATE="$TODAY"
|
||||
;;
|
||||
ytd)
|
||||
FROM_DATE=$(date +%Y.01.01)
|
||||
TO_DATE="$TODAY"
|
||||
;;
|
||||
last_year)
|
||||
PREV_YEAR=$(( $(date +%Y) - 1 ))
|
||||
FROM_DATE="${PREV_YEAR}.01.01"
|
||||
TO_DATE="${PREV_YEAR}.12.31"
|
||||
;;
|
||||
*) echo "ERROR: Unknown preset: $PRESET" >&2; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
[[ -z "$FROM_DATE" || -z "$TO_DATE" ]] && {
|
||||
echo "ERROR: Provide --from/--to dates or --preset" >&2; exit 1
|
||||
}
|
||||
|
||||
# ── Report directory ──────────────────────────────────────────────────────────
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
REPORT_ID="${TIMESTAMP}_${EXPERT}_${SYMBOL}_${TIMEFRAME}"
|
||||
REPORT_DIR="${REPORTS_DIR}/${REPORT_ID}"
|
||||
mkdir -p "$REPORT_DIR"
|
||||
|
||||
PIPELINE_START=$(date +%s)
|
||||
PROGRESS_LOG="${REPORT_DIR}/progress.log"
|
||||
_progress() { echo "$1 $(date -u +%Y-%m-%dT%H:%M:%SZ) elapsed=$(( $(date +%s) - PIPELINE_START ))" >> "$PROGRESS_LOG"; }
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " MT5-Quant Backtest Pipeline"
|
||||
echo " Expert: $EXPERT"
|
||||
echo " Symbol: $SYMBOL Timeframe: $TIMEFRAME Model: $MODEL"
|
||||
echo " Period: $FROM_DATE → $TO_DATE"
|
||||
echo " Deposit: $CURRENCY $DEPOSIT Leverage: 1:$LEVERAGE"
|
||||
[[ -n "$SET_FILE" ]] && echo " Set file: $SET_FILE"
|
||||
echo " Report: $REPORT_DIR"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# ── Resolve platform ──────────────────────────────────────────────────────────
|
||||
resolve_platform
|
||||
|
||||
# ── Stage 1: COMPILE ──────────────────────────────────────────────────────────
|
||||
if [[ "$SKIP_COMPILE" == false ]]; then
|
||||
_progress "COMPILE"
|
||||
echo ""
|
||||
echo "[1/5] COMPILE"
|
||||
|
||||
# Find source file — check project.dir first, then fall back to pipeline root
|
||||
EA_SOURCE=""
|
||||
search_paths=(
|
||||
"${ROOT_DIR}/src/experts/${EXPERT}.mq5"
|
||||
"${ROOT_DIR}/src/${EXPERT}.mq5"
|
||||
"${ROOT_DIR}/${EXPERT}.mq5"
|
||||
)
|
||||
if [[ -n "$PROJECT_DIR" ]]; then
|
||||
search_paths=(
|
||||
"${PROJECT_DIR}/src/experts/${EXPERT}.mq5"
|
||||
"${PROJECT_DIR}/src/${EXPERT}.mq5"
|
||||
"${PROJECT_DIR}/${EXPERT}.mq5"
|
||||
"${search_paths[@]}"
|
||||
)
|
||||
fi
|
||||
for search_path in "${search_paths[@]}"; do
|
||||
[[ -f "$search_path" ]] && { EA_SOURCE="$search_path"; break; }
|
||||
done
|
||||
|
||||
[[ -z "$EA_SOURCE" ]] && {
|
||||
echo " ERROR: Cannot find ${EXPERT}.mq5" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
"${SCRIPT_DIR}/mqlcompile.sh" "$EA_SOURCE"
|
||||
else
|
||||
echo "[1/5] COMPILE skipped"
|
||||
fi
|
||||
|
||||
# ── Stage 2: CLEAN ────────────────────────────────────────────────────────────
|
||||
if [[ "$SKIP_CLEAN" == false ]]; then
|
||||
_progress "CLEAN"
|
||||
echo ""
|
||||
echo "[2/5] CLEAN"
|
||||
|
||||
# Clear tester cache
|
||||
if [[ -d "$MT5_CACHE_DIR" ]]; then
|
||||
find "$MT5_CACHE_DIR" -name "*.tst" -delete 2>/dev/null || true
|
||||
echo " Cleared tester cache: $MT5_CACHE_DIR"
|
||||
fi
|
||||
|
||||
# Remove cached .set file for this expert
|
||||
CACHED_SET="${MT5_TESTER_DIR}/${EXPERT}.set"
|
||||
if [[ -f "$CACHED_SET" ]]; then
|
||||
rm -f "$CACHED_SET"
|
||||
echo " Removed cached .set: $CACHED_SET"
|
||||
fi
|
||||
|
||||
# Reset terminal.ini OptMode — after any test/optimization MT5 sets OptMode=-1
|
||||
# which causes the next headless run to exit immediately (exit 49, no report)
|
||||
TERMINAL_INI="${MT5_DIR}/config/terminal.ini"
|
||||
if [[ -f "$TERMINAL_INI" ]]; then
|
||||
python3 -c "
|
||||
import sys, re
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
raw = f.read()
|
||||
# Detect encoding: UTF-16 with BOM, or plain text
|
||||
if raw[:2] in (b'\xff\xfe', b'\xfe\xff'):
|
||||
text = raw.decode('utf-16')
|
||||
encoding = 'utf-16'
|
||||
else:
|
||||
text = raw.decode('utf-8', errors='replace')
|
||||
encoding = 'utf-8'
|
||||
text = re.sub(r'(?m)^OptMode=-1\s*$', 'OptMode=0', text)
|
||||
text = re.sub(r'(?m)^LastOptimization=1\s*\n?', '', text)
|
||||
with open(path, 'wb') as f:
|
||||
f.write(text.encode(encoding))
|
||||
print(' Reset OptMode=-1 -> OptMode=0 in terminal.ini')
|
||||
except Exception as e:
|
||||
print(f' Warning: could not reset OptMode in terminal.ini: {e}')
|
||||
" "$TERMINAL_INI" 2>/dev/null || true
|
||||
echo " Reset terminal.ini OptMode"
|
||||
fi
|
||||
else
|
||||
echo "[2/5] CLEAN skipped"
|
||||
fi
|
||||
|
||||
# ── Prepare .set file ─────────────────────────────────────────────────────────
|
||||
if [[ -n "$SET_FILE" ]]; then
|
||||
# Resolve relative paths against PROJECT_DIR (fallback: script ROOT_DIR, then CWD)
|
||||
if [[ ! -f "$SET_FILE" ]]; then
|
||||
for base in "$PROJECT_DIR" "$ROOT_DIR" "$(pwd)"; do
|
||||
[[ -n "$base" && -f "${base}/${SET_FILE}" ]] && { SET_FILE="${base}/${SET_FILE}"; break; }
|
||||
done
|
||||
fi
|
||||
if [[ ! -f "$SET_FILE" ]]; then
|
||||
echo "ERROR: Set file not found: $SET_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Copy to tester profiles dir (MT5 reads from here)
|
||||
mkdir -p "$MT5_TESTER_DIR"
|
||||
cp "$SET_FILE" "${MT5_TESTER_DIR}/${EXPERT}.set"
|
||||
SET_FILENAME="$(basename "$SET_FILE")"
|
||||
fi
|
||||
|
||||
# ── Stage 3: BACKTEST ─────────────────────────────────────────────────────────
|
||||
_progress "BACKTEST"
|
||||
echo ""
|
||||
echo "[3/5] BACKTEST"
|
||||
|
||||
# Detect running MT5 instance (match terminal64.exe regardless of wine wrapper name)
|
||||
_mt5_is_running() { pgrep -f "terminal64\.exe" > /dev/null 2>&1; }
|
||||
|
||||
MT5_WAS_RUNNING=false
|
||||
_mt5_is_running && MT5_WAS_RUNNING=true
|
||||
|
||||
# --kill-existing: terminate running MT5 before launch (use with --shutdown for CI)
|
||||
if $KILL_EXISTING && $MT5_WAS_RUNNING; then
|
||||
echo " Stopping running MT5 (--kill-existing)..."
|
||||
pkill -TERM -f "terminal64\.exe" 2>/dev/null || true
|
||||
for _i in 1 2 3 4 5; do
|
||||
sleep 1
|
||||
_mt5_is_running || break
|
||||
done
|
||||
_mt5_is_running && { pkill -KILL -f "terminal64\.exe" 2>/dev/null || true; sleep 1; }
|
||||
echo " MT5 stopped."
|
||||
MT5_WAS_RUNNING=false
|
||||
fi
|
||||
|
||||
# Build backtest.ini
|
||||
REPORT_FILENAME="${REPORT_ID}.htm"
|
||||
# Relative path — MT5 resolves against its working dir (C:\Program Files\MetaTrader 5\reports\)
|
||||
WINE_REPORT_PATH="reports\\${REPORT_FILENAME}"
|
||||
|
||||
INI_HOST_PATH="${MT5_DIR}/backtest_config.ini"
|
||||
REPORTS_HOST_DIR="${MT5_DIR}/reports"
|
||||
mkdir -p "$REPORTS_HOST_DIR"
|
||||
|
||||
INI_CONTENT=""
|
||||
if [[ -n "$DEFAULT_LOGIN" && -n "$DEFAULT_SERVER" ]]; then
|
||||
INI_CONTENT="[Common]
|
||||
Login=${DEFAULT_LOGIN}
|
||||
Server=${DEFAULT_SERVER}
|
||||
|
||||
"
|
||||
fi
|
||||
|
||||
# ShutdownTerminal=0 (default): MT5 stays open; report detected via file watching.
|
||||
# ShutdownTerminal=1 (--shutdown): MT5 exits after backtest; process-wait mode.
|
||||
SHUTDOWN_VAL=0
|
||||
$SHUTDOWN_TERMINAL && SHUTDOWN_VAL=1
|
||||
|
||||
INI_CONTENT+="[Tester]
|
||||
Expert=${EXPERT}.ex5
|
||||
Symbol=${SYMBOL}
|
||||
Period=${TIMEFRAME}
|
||||
Optimization=0
|
||||
Model=${MODEL}
|
||||
FromDate=${FROM_DATE}
|
||||
ToDate=${TO_DATE}
|
||||
ForwardMode=0
|
||||
Deposit=${DEPOSIT}
|
||||
Currency=${CURRENCY}
|
||||
ProfitInPips=1
|
||||
Leverage=${LEVERAGE}
|
||||
ExecutionMode=10
|
||||
OptimizationCriterion=0
|
||||
Visual=$([[ "$GUI_MODE" == true ]] && echo 1 || echo 0)
|
||||
Report=${WINE_REPORT_PATH}
|
||||
ReplaceReport=1
|
||||
ShutdownTerminal=${SHUTDOWN_VAL}
|
||||
"
|
||||
[[ -n "$SET_FILE" ]] && INI_CONTENT+="ExpertParameters=${EXPERT}.set
|
||||
"
|
||||
|
||||
# MT5 requires UTF-16LE with BOM — plain UTF-8 is silently ignored
|
||||
printf "%s" "$INI_CONTENT" | iconv -f UTF-8 -t UTF-16LE > "${INI_HOST_PATH}.tmp"
|
||||
printf '\xff\xfe' | cat - "${INI_HOST_PATH}.tmp" > "${INI_HOST_PATH}"
|
||||
rm -f "${INI_HOST_PATH}.tmp"
|
||||
|
||||
# Set Wine prefix
|
||||
WINE_PREFIX_DIR=$(dirname "$(dirname "$(dirname "$MT5_DIR")")")
|
||||
export WINEPREFIX="$WINE_PREFIX_DIR"
|
||||
export WINEDEBUG="-all"
|
||||
|
||||
BACKTEST_START=$(date +%s)
|
||||
BAT_PATH="${WINE_PREFIX_DIR}/drive_c/_mt5mcp_run.bat"
|
||||
|
||||
if $SHUTDOWN_TERMINAL; then
|
||||
# ── Synchronous mode (--shutdown) ─────────────────────────────────────────
|
||||
# terminal64.exe is a single-instance app: if MT5 is running, a second
|
||||
# launch exits immediately with no report. Kill first to avoid this.
|
||||
if $MT5_WAS_RUNNING; then
|
||||
echo " WARNING: MT5 is running — stopping it (required for --shutdown mode)."
|
||||
pkill -TERM -f "terminal64\.exe" 2>/dev/null || true
|
||||
for _i in 1 2 3 4 5; do sleep 1; _mt5_is_running || break; done
|
||||
_mt5_is_running && { pkill -KILL -f "terminal64\.exe" 2>/dev/null || true; sleep 1; }
|
||||
echo " MT5 stopped."
|
||||
fi
|
||||
cat > "$BAT_PATH" << 'BATEOF'
|
||||
@echo off
|
||||
cd /d "C:\Program Files\MetaTrader 5"
|
||||
start /wait terminal64.exe /config:"C:\Program Files\MetaTrader 5\backtest_config.ini"
|
||||
BATEOF
|
||||
echo " Launching MT5 (timeout: ${TIMEOUT}s, shutdown mode)..."
|
||||
set +e
|
||||
timeout "${TIMEOUT}" ${MT5_ARCH} "${MT5_WINE}" cmd.exe /c 'C:\_mt5mcp_run.bat' 2>/dev/null
|
||||
WINE_EXIT=$?
|
||||
set -e
|
||||
rm -f "$BAT_PATH"
|
||||
BACKTEST_ELAPSED=$(( $(date +%s) - BACKTEST_START ))
|
||||
echo " MT5 completed in ${BACKTEST_ELAPSED}s (exit: ${WINE_EXIT})"
|
||||
sleep 2
|
||||
# Locate report
|
||||
MT5_REPORT=""
|
||||
for ext in ".htm" ".htm.xml" ".html"; do
|
||||
candidate="${REPORTS_HOST_DIR}/${REPORT_ID}${ext}"
|
||||
[[ -f "$candidate" ]] && { MT5_REPORT="$candidate"; break; }
|
||||
done
|
||||
[[ -z "$MT5_REPORT" ]] && \
|
||||
MT5_REPORT=$(find "${MT5_DIR}" -maxdepth 3 -name "*.htm" -newer "${INI_HOST_PATH}" 2>/dev/null | head -1)
|
||||
else
|
||||
# ── Background mode (default) ──────────────────────────────────────────────
|
||||
# MT5 uses a single-instance lock per Wine prefix. On Wine/CrossOver, a second
|
||||
# terminal64.exe instance exits immediately without forwarding the config to
|
||||
# the running instance (unlike native Windows behaviour).
|
||||
# When MT5 is running, we must kill it first — but with ShutdownTerminal=0
|
||||
# the new instance stays open after the backtest, so the user can inspect
|
||||
# results in the GUI without a permanent disruption.
|
||||
if $MT5_WAS_RUNNING; then
|
||||
echo "" >&2
|
||||
echo " ERROR: MetaTrader 5 is already running." >&2
|
||||
echo "" >&2
|
||||
echo " On Wine/CrossOver a second MT5 instance cannot pass a backtest config" >&2
|
||||
echo " to the running terminal — it exits immediately with no report." >&2
|
||||
echo "" >&2
|
||||
echo " Recommended fix:" >&2
|
||||
echo " Add --kill-existing to your command." >&2
|
||||
echo " MT5 will restart, run the backtest, then stay open (ShutdownTerminal=0)" >&2
|
||||
echo " so you can inspect the Strategy Tester results in the GUI." >&2
|
||||
echo "" >&2
|
||||
echo " Alternative: close MT5 manually and re-run the backtest." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Launching MT5 in background (ShutdownTerminal=0) ..."
|
||||
# `start` (no /wait): cmd.exe launches terminal64.exe and exits immediately.
|
||||
cat > "$BAT_PATH" << 'BATEOF'
|
||||
@echo off
|
||||
cd /d "C:\Program Files\MetaTrader 5"
|
||||
start terminal64.exe /config:"C:\Program Files\MetaTrader 5\backtest_config.ini"
|
||||
BATEOF
|
||||
nohup ${MT5_ARCH} "${MT5_WINE}" cmd.exe /c 'C:\_mt5mcp_run.bat' &>/dev/null &
|
||||
LAUNCHER_PID=$!
|
||||
disown "$LAUNCHER_PID" 2>/dev/null || true
|
||||
sleep 5
|
||||
rm -f "$BAT_PATH"
|
||||
|
||||
# ── Poll for report file ───────────────────────────────────────────────────
|
||||
# MT5 writes the report when Strategy Tester finishes, before any shutdown.
|
||||
REPORT_DEADLINE=$(( $(date +%s) + TIMEOUT ))
|
||||
MT5_REPORT=""
|
||||
echo " Waiting for backtest report (timeout: ${TIMEOUT}s)..."
|
||||
while [[ -z "$MT5_REPORT" ]]; do
|
||||
if [[ $(date +%s) -ge $REPORT_DEADLINE ]]; then
|
||||
echo " ERROR: Timeout (${TIMEOUT}s) — no report produced." >&2
|
||||
echo " Possible causes:" >&2
|
||||
echo " - Symbol not found in MT5 history for this broker" >&2
|
||||
echo " - EA name mismatch (check .ex5 exists in Experts/)" >&2
|
||||
if $MT5_WAS_RUNNING; then
|
||||
echo " - Running MT5 ignored the config (try: --kill-existing)" >&2
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
sleep 5
|
||||
ELAPSED=$(( $(date +%s) - BACKTEST_START ))
|
||||
for ext in ".htm" ".htm.xml" ".html"; do
|
||||
candidate="${REPORTS_HOST_DIR}/${REPORT_ID}${ext}"
|
||||
if [[ -f "$candidate" && -s "$candidate" ]]; then
|
||||
# Wait for file to stabilise (fully flushed to disk)
|
||||
S1=$(stat -f%z "$candidate" 2>/dev/null || stat -c%s "$candidate")
|
||||
sleep 2
|
||||
S2=$(stat -f%z "$candidate" 2>/dev/null || stat -c%s "$candidate")
|
||||
[[ "$S1" -gt 0 && "$S1" == "$S2" ]] && { MT5_REPORT="$candidate"; break 2; }
|
||||
fi
|
||||
done
|
||||
printf " ... %ds elapsed\r" "$ELAPSED"
|
||||
done
|
||||
BACKTEST_ELAPSED=$(( $(date +%s) - BACKTEST_START ))
|
||||
echo " Report ready in ${BACKTEST_ELAPSED}s"
|
||||
fi
|
||||
|
||||
# ── Locate report file ────────────────────────────────────────────────────────
|
||||
if [[ -z "$MT5_REPORT" ]]; then
|
||||
echo " ERROR: MT5 produced no report." >&2
|
||||
echo " Check: symbol name, date range, EA name, and that MT5 ran to completion." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " Report: $MT5_REPORT"
|
||||
|
||||
# ── Stage 4: EXTRACT ─────────────────────────────────────────────────────────
|
||||
_progress "EXTRACT"
|
||||
echo ""
|
||||
echo "[4/5] EXTRACT"
|
||||
|
||||
python3 "${ROOT_DIR}/analytics/extract.py" \
|
||||
"$MT5_REPORT" \
|
||||
--output-dir "$REPORT_DIR" \
|
||||
&& echo " → metrics.json, deals.csv, deals.json"
|
||||
|
||||
# ── Stage 5: ANALYZE ─────────────────────────────────────────────────────────
|
||||
if [[ "$SKIP_ANALYZE" == false ]]; then
|
||||
_progress "ANALYZE"
|
||||
echo ""
|
||||
echo "[5/5] ANALYZE"
|
||||
|
||||
ANALYZE_FLAGS="$STRATEGY"
|
||||
[[ "$DEEP_ANALYZE" == true ]] && ANALYZE_FLAGS="$ANALYZE_FLAGS --deep"
|
||||
|
||||
python3 "${ROOT_DIR}/analytics/analyze.py" \
|
||||
$ANALYZE_FLAGS \
|
||||
"${REPORT_DIR}/deals.csv" \
|
||||
--output-dir "$REPORT_DIR" \
|
||||
&& echo " → analysis.json [$STRATEGY]"
|
||||
else
|
||||
echo "[5/5] ANALYZE skipped"
|
||||
fi
|
||||
|
||||
# ── Save pipeline metadata ────────────────────────────────────────────────────
|
||||
_progress "DONE"
|
||||
PIPELINE_ELAPSED=$(( $(date +%s) - PIPELINE_START ))
|
||||
|
||||
python3 - << PYEOF
|
||||
import json, os
|
||||
meta = {
|
||||
"expert": "${EXPERT}",
|
||||
"symbol": "${SYMBOL}",
|
||||
"timeframe": "${TIMEFRAME}",
|
||||
"from_date": "${FROM_DATE}",
|
||||
"to_date": "${TO_DATE}",
|
||||
"deposit": ${DEPOSIT},
|
||||
"currency": "${CURRENCY}",
|
||||
"model": ${MODEL},
|
||||
"leverage": ${LEVERAGE},
|
||||
"set_file": "${SET_FILE}",
|
||||
"report_dir": "${REPORT_DIR}",
|
||||
"duration_seconds": ${PIPELINE_ELAPSED},
|
||||
"files": {
|
||||
"metrics": "${REPORT_DIR}/metrics.json",
|
||||
"analysis": "${REPORT_DIR}/analysis.json",
|
||||
"deals_csv": "${REPORT_DIR}/deals.csv",
|
||||
"deals_json": "${REPORT_DIR}/deals.json"
|
||||
}
|
||||
}
|
||||
with open("${REPORT_DIR}/pipeline_metadata.json", "w") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
PYEOF
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Pipeline complete in ${PIPELINE_ELAPSED}s"
|
||||
echo " Report: $REPORT_DIR"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# Print key metrics inline
|
||||
if [[ -f "${REPORT_DIR}/metrics.json" ]]; then
|
||||
python3 - << PYEOF
|
||||
import json
|
||||
with open("${REPORT_DIR}/metrics.json") as f:
|
||||
m = json.load(f)
|
||||
print(f" Profit: \${m.get('net_profit',0):,.2f} PF: {m.get('profit_factor',0):.2f} DD: {m.get('max_dd_pct',0):.2f}% Sharpe: {m.get('sharpe_ratio',0):.2f} Trades: {m.get('total_trades',0)}")
|
||||
PYEOF
|
||||
fi
|
||||
@@ -1,85 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build MT5-Quant as a directory bundle (onedir mode) using PyInstaller
|
||||
# This mode preserves stdin/stdout better for MCP communication
|
||||
# Output: dist/mt5-quant/ directory
|
||||
|
||||
set -e
|
||||
|
||||
# Get script directory and project root
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== MT5-Quant PyInstaller Build (ONEDIR Mode) ==="
|
||||
echo "Project root: $PROJECT_ROOT"
|
||||
echo ""
|
||||
|
||||
# Determine Python interpreter
|
||||
if [ -f "$PROJECT_ROOT/.venv/bin/python3" ]; then
|
||||
PYTHON="$PROJECT_ROOT/.venv/bin/python3"
|
||||
elif [ -f "$PROJECT_ROOT/.venv/bin/python" ]; then
|
||||
PYTHON="$PROJECT_ROOT/.venv/bin/python"
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
|
||||
echo "Using Python: $PYTHON"
|
||||
echo ""
|
||||
|
||||
# Ensure dependencies are installed
|
||||
echo "Installing dependencies..."
|
||||
$PYTHON -m pip install pyinstaller mcp pyyaml typer --quiet 2>/dev/null || true
|
||||
|
||||
# Clean previous builds
|
||||
echo "Cleaning previous builds..."
|
||||
rm -rf "$PROJECT_ROOT/build" "$PROJECT_ROOT/dist"
|
||||
|
||||
# Build the executable in onedir mode
|
||||
echo "Building executable in ONEDIR mode (this may take 1-2 minutes)..."
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
# Use onedir mode (no --onefile flag)
|
||||
# Note: mode is controlled by the .spec file
|
||||
$PYTHON -m PyInstaller \
|
||||
--clean \
|
||||
--noconfirm \
|
||||
--distpath "$PROJECT_ROOT/dist" \
|
||||
--workpath "$PROJECT_ROOT/build" \
|
||||
"$PROJECT_ROOT/mt5-quant.spec"
|
||||
|
||||
# Report results
|
||||
echo ""
|
||||
echo "=== Build Complete ==="
|
||||
echo ""
|
||||
echo "Executable location:"
|
||||
ls -la "$PROJECT_ROOT/dist/mt5-quant/" | head -10
|
||||
|
||||
echo ""
|
||||
echo "Main executable:"
|
||||
ls -lh "$PROJECT_ROOT/dist/mt5-quant/mt5-quant"
|
||||
|
||||
echo ""
|
||||
echo "Total size:"
|
||||
du -sh "$PROJECT_ROOT/dist/mt5-quant/"
|
||||
|
||||
echo ""
|
||||
echo "To test:"
|
||||
echo " ./dist/mt5-quant/mt5-quant"
|
||||
echo ""
|
||||
echo "To register with Claude Code:"
|
||||
echo " claude mcp add MT5-Quant -- $(pwd)/dist/mt5-quant/mt5-quant"
|
||||
echo ""
|
||||
echo "=== Deployment to Multiple Machines ==="
|
||||
echo ""
|
||||
echo "1. Copy entire directory to target machines:"
|
||||
echo " scp -r dist/mt5-quant/ user@server:/opt/"
|
||||
echo " ssh user@server ln -s /opt/mt5-quant/mt5-quant /usr/local/bin/"
|
||||
echo ""
|
||||
echo "2. Or create tarball for distribution:"
|
||||
echo " tar -czf mt5-quant-macos-arm64.tar.gz -C dist mt5-quant"
|
||||
echo ""
|
||||
echo "Requirements on target machine:"
|
||||
echo " - MetaTrader 5 installed (via Wine on macOS/Linux)"
|
||||
echo " - Config file at ~/.config/mt5-quant/config/mt5-quant.yaml"
|
||||
echo " - NO Python installation required!"
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Build MT5-Quant as a single executable using PyInstaller
|
||||
# Output: dist/mt5-quant
|
||||
|
||||
set -e
|
||||
|
||||
# Get script directory and project root
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
echo "=== MT5-Quant PyInstaller Build ==="
|
||||
echo "Project root: $PROJECT_ROOT"
|
||||
echo ""
|
||||
|
||||
# Determine Python interpreter
|
||||
if [ -f "$PROJECT_ROOT/.venv/bin/python3" ]; then
|
||||
PYTHON="$PROJECT_ROOT/.venv/bin/python3"
|
||||
elif [ -f "$PROJECT_ROOT/.venv/bin/python" ]; then
|
||||
PYTHON="$PROJECT_ROOT/.venv/bin/python"
|
||||
else
|
||||
PYTHON="python3"
|
||||
fi
|
||||
|
||||
echo "Using Python: $PYTHON"
|
||||
echo ""
|
||||
|
||||
# Ensure dependencies are installed
|
||||
echo "Installing dependencies..."
|
||||
$PYTHON -m pip install pyinstaller mcp pyyaml --quiet
|
||||
|
||||
# Clean previous builds
|
||||
echo "Cleaning previous builds..."
|
||||
rm -rf "$PROJECT_ROOT/build" "$PROJECT_ROOT/dist"
|
||||
|
||||
# Build the executable
|
||||
echo "Building executable (this may take 1-2 minutes)..."
|
||||
cd "$PROJECT_ROOT"
|
||||
$PYTHON -m PyInstaller \
|
||||
--clean \
|
||||
--noconfirm \
|
||||
--distpath "$PROJECT_ROOT/dist" \
|
||||
--workpath "$PROJECT_ROOT/build" \
|
||||
"$PROJECT_ROOT/mt5-quant.spec"
|
||||
|
||||
# Report results
|
||||
echo ""
|
||||
echo "=== Build Complete ==="
|
||||
echo ""
|
||||
echo "Executable location:"
|
||||
ls -lh dist/mt5-quant
|
||||
|
||||
echo ""
|
||||
echo "Size breakdown:"
|
||||
du -sh dist/ 2>/dev/null || echo " dist/: $(du -sh dist/mt5-quant 2>/dev/null | cut -f1)"
|
||||
|
||||
echo ""
|
||||
echo "To test:"
|
||||
echo " ./dist/mt5-quant --help"
|
||||
echo ""
|
||||
echo "To register with Claude Code:"
|
||||
echo " claude mcp add MT5-Quant -- $(pwd)/dist/mt5-quant"
|
||||
echo ""
|
||||
echo "=== Deployment to Multiple Machines ==="
|
||||
echo ""
|
||||
echo "1. Copy this single binary to target machines:"
|
||||
echo " scp dist/mt5-quant user@server:/usr/local/bin/"
|
||||
echo " ssh user@server chmod +x /usr/local/bin/mt5-quant"
|
||||
echo ""
|
||||
echo "2. Copy config directory (includes mt5-quant.yaml):"
|
||||
echo " scp -r config/ user@server:~/.config/mt5-quant/"
|
||||
echo ""
|
||||
echo "3. Or use bundled config (minimal):"
|
||||
echo " MT5_MCP_HOME=/path/to/config ./mt5-quant"
|
||||
echo ""
|
||||
echo "4. Register on target machine:"
|
||||
echo " claude mcp add MT5-Quant -- /usr/local/bin/mt5-quant"
|
||||
echo ""
|
||||
echo "Requirements on target machine:"
|
||||
echo " - MetaTrader 5 installed (via Wine on macOS/Linux)"
|
||||
echo " - Config file at ~/.config/mt5-quant/config/mt5-quant.yaml"
|
||||
echo " - NO Python installation required!"
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
# Build release binaries for distribution
|
||||
# Creates: dist/mt5-quant-{platform}.tar.gz
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
VERSION=$(grep -E '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
|
||||
echo "=== Building MT5-Quant v${VERSION} ==="
|
||||
echo ""
|
||||
|
||||
# Clean previous builds
|
||||
rm -rf "$PROJECT_ROOT/dist"
|
||||
mkdir -p "$PROJECT_ROOT/dist"
|
||||
|
||||
# Build current platform
|
||||
echo "Building for current platform..."
|
||||
cargo build --release
|
||||
|
||||
# Detect platform
|
||||
UNAME=$(uname -s)
|
||||
ARCH=$(uname -m)
|
||||
|
||||
if [[ "$UNAME" == "Darwin" ]]; then
|
||||
PLATFORM="macos-${ARCH}"
|
||||
elif [[ "$UNAME" == "Linux" ]]; then
|
||||
PLATFORM="linux-${ARCH}"
|
||||
else
|
||||
PLATFORM="unknown"
|
||||
fi
|
||||
|
||||
PACKAGE_NAME="mt5-quant-${PLATFORM}"
|
||||
PACKAGE_DIR="$PROJECT_ROOT/dist/${PACKAGE_NAME}"
|
||||
|
||||
echo "Packaging for ${PLATFORM}..."
|
||||
mkdir -p "$PACKAGE_DIR"
|
||||
|
||||
# Copy binary
|
||||
cp "$PROJECT_ROOT/target/release/mt5-quant" "$PACKAGE_DIR/"
|
||||
|
||||
# Copy config template
|
||||
mkdir -p "$PACKAGE_DIR/config"
|
||||
cp "$PROJECT_ROOT/config/mt5-quant.example.yaml" "$PACKAGE_DIR/config/"
|
||||
|
||||
# Copy docs
|
||||
cp "$PROJECT_ROOT/README.md" "$PACKAGE_DIR/"
|
||||
cp "$PROJECT_ROOT/WINDSURF_SETUP.md" "$PACKAGE_DIR/"
|
||||
cp "$PROJECT_ROOT/CLAUDE.md" "$PACKAGE_DIR/"
|
||||
|
||||
# Create tarball
|
||||
cd "$PROJECT_ROOT/dist"
|
||||
tar -czf "${PACKAGE_NAME}.tar.gz" "$PACKAGE_NAME"
|
||||
|
||||
echo ""
|
||||
echo "=== Build Complete ==="
|
||||
echo ""
|
||||
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
|
||||
echo ""
|
||||
echo "To install:"
|
||||
echo " tar -xzf ${PACKAGE_NAME}.tar.gz"
|
||||
echo " cd ${PACKAGE_NAME}"
|
||||
echo " ./mt5-quant --help"
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
# Build MT5-Quant Rust MCP Server
|
||||
# Output: target/release/mt5-quant
|
||||
|
||||
set -e
|
||||
|
||||
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 ""
|
||||
|
||||
echo "Building release binary..."
|
||||
cargo build --release
|
||||
|
||||
echo ""
|
||||
echo "=== Build Complete ==="
|
||||
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 ""
|
||||
@@ -1,143 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# mqlcompile.sh — Compile an MQL5 Expert Advisor via MetaEditor (Wine/CrossOver)
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/mqlcompile.sh <path/to/Expert.mq5>
|
||||
#
|
||||
# Output:
|
||||
# Compiled .ex5 written to MT5_EXPERTS_DIR
|
||||
# Exit 0 on success, 1 on compile errors
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "${SCRIPT_DIR}/platform_detect.sh"
|
||||
|
||||
# ── Args ──────────────────────────────────────────────────────────────────────
|
||||
SOURCE_FILE="${1:-}"
|
||||
if [[ -z "$SOURCE_FILE" ]]; then
|
||||
echo "Usage: $0 <path/to/Expert.mq5>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$SOURCE_FILE" ]]; then
|
||||
echo "ERROR: Source file not found: $SOURCE_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SOURCE_FILE="$(realpath "$SOURCE_FILE")"
|
||||
EXPERT_NAME="$(basename "$SOURCE_FILE" .mq5)"
|
||||
|
||||
# ── Resolve platform ──────────────────────────────────────────────────────────
|
||||
resolve_platform
|
||||
|
||||
METAEDITOR="${MT5_DIR}/metaeditor64.exe"
|
||||
if [[ ! -f "$METAEDITOR" ]]; then
|
||||
echo "ERROR: metaeditor64.exe not found at: $METAEDITOR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Copy source to MT5 Experts source dir ────────────────────────────────────
|
||||
# MetaEditor requires the source file to be inside the MT5 directory tree
|
||||
MT5_SRC_DIR="${MT5_DIR}/MQL5/Experts"
|
||||
mkdir -p "$MT5_SRC_DIR"
|
||||
cp "$SOURCE_FILE" "${MT5_SRC_DIR}/${EXPERT_NAME}.mq5"
|
||||
|
||||
WINE_SRC_PATH="$(host_to_wine_path "${MT5_SRC_DIR}/${EXPERT_NAME}.mq5")"
|
||||
|
||||
# ── Sync .mqh include files to MT5 Include dir ───────────────────────────────
|
||||
# Auto-detect include/ directory relative to source file. Searches up to 2
|
||||
# levels above the source file for an include/ sibling directory.
|
||||
# Layout supported:
|
||||
# <project>/experts/EA.mq5 + <project>/include/<subdir>/*.mqh
|
||||
# <project>/src/experts/EA.mq5 + <project>/src/include/<subdir>/*.mqh
|
||||
_find_include_dir() {
|
||||
local source_dir="$1"
|
||||
local candidate
|
||||
for candidate in "$source_dir" "$(dirname "$source_dir")" "$(dirname "$(dirname "$source_dir")")"; do
|
||||
if [[ -d "${candidate}/include" ]]; then
|
||||
echo "${candidate}/include"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
INCLUDE_BASE=""
|
||||
INCLUDE_BASE=$(_find_include_dir "$(dirname "$SOURCE_FILE")") || true
|
||||
|
||||
if [[ -n "$INCLUDE_BASE" ]]; then
|
||||
synced_total=0
|
||||
# Sync each subdirectory under include/ → MQL5/Include/<subdir>/
|
||||
while IFS= read -r -d '' subdir; do
|
||||
dir_name="$(basename "$subdir")"
|
||||
mt5_dest="${MT5_DIR}/MQL5/Include/${dir_name}"
|
||||
rm -rf "$mt5_dest"
|
||||
cp -r "$subdir" "$mt5_dest"
|
||||
count=$(find "$mt5_dest" -name "*.mqh" | wc -l | tr -d ' ')
|
||||
echo "[compile] Synced ${count} .mqh → Include/${dir_name}/"
|
||||
synced_total=$((synced_total + count))
|
||||
done < <(find "$INCLUDE_BASE" -mindepth 1 -maxdepth 1 -type d -print0 2>/dev/null)
|
||||
|
||||
# Also sync any .mqh files directly in include/ (flat layout)
|
||||
while IFS= read -r -d '' mqh; do
|
||||
cp "$mqh" "${MT5_DIR}/MQL5/Include/"
|
||||
synced_total=$((synced_total + 1))
|
||||
done < <(find "$INCLUDE_BASE" -maxdepth 1 -name "*.mqh" -print0 2>/dev/null)
|
||||
|
||||
if [[ $synced_total -eq 0 ]]; then
|
||||
echo "[compile] INFO: include/ found but contains no .mqh files — skipping sync"
|
||||
else
|
||||
echo "[compile] Synced ${synced_total} .mqh file(s) total"
|
||||
fi
|
||||
else
|
||||
echo "[compile] INFO: No include/ directory found — skipping .mqh sync"
|
||||
fi
|
||||
|
||||
# ── Set Wine prefix ───────────────────────────────────────────────────────────
|
||||
WINE_PREFIX_DIR=$(dirname "$(dirname "$(dirname "$MT5_DIR")")")
|
||||
export WINEPREFIX="$WINE_PREFIX_DIR"
|
||||
export WINEDEBUG="-all"
|
||||
|
||||
# ── Run MetaEditor ────────────────────────────────────────────────────────────
|
||||
echo "[compile] Compiling ${EXPERT_NAME}.mq5 ..."
|
||||
LOG_FILE="$(mktemp /tmp/mqlcompile_XXXXXX.log)"
|
||||
|
||||
set +e
|
||||
${MT5_ARCH} "${MT5_WINE}" "${METAEDITOR}" \
|
||||
/compile:"${WINE_SRC_PATH}" \
|
||||
/log:"${LOG_FILE}" \
|
||||
2>/dev/null
|
||||
WINE_EXIT=$?
|
||||
set -e
|
||||
|
||||
# MetaEditor always exits 0 on macOS/Wine; check log for errors
|
||||
ERRORS=0
|
||||
WARNINGS=0
|
||||
if [[ -f "$LOG_FILE" ]]; then
|
||||
# Log may be UTF-16LE
|
||||
LOG_TEXT=$(iconv -f UTF-16LE -t UTF-8 "$LOG_FILE" 2>/dev/null || cat "$LOG_FILE")
|
||||
ERRORS=$(echo "$LOG_TEXT" | grep -cE "^.*error" || true)
|
||||
WARNINGS=$(echo "$LOG_TEXT" | grep -cE "^.*warning" || true)
|
||||
|
||||
if [[ $ERRORS -gt 0 ]]; then
|
||||
echo "[compile] FAILED: $ERRORS error(s), $WARNINGS warning(s)"
|
||||
echo "$LOG_TEXT" | grep -E "error|warning" | head -20
|
||||
rm -f "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Verify .ex5 was produced ──────────────────────────────────────────────────
|
||||
EX5_PATH="${MT5_SRC_DIR}/${EXPERT_NAME}.ex5"
|
||||
if [[ ! -f "$EX5_PATH" ]]; then
|
||||
echo "[compile] ERROR: .ex5 not produced. MetaEditor may have failed silently." >&2
|
||||
[[ -f "$LOG_FILE" ]] && cat "$LOG_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BINARY_SIZE=$(stat -f%z "$EX5_PATH" 2>/dev/null || stat -c%s "$EX5_PATH")
|
||||
echo "[compile] OK: ${EXPERT_NAME}.ex5 (${BINARY_SIZE} bytes, ${WARNINGS} warning(s))"
|
||||
|
||||
rm -f "$LOG_FILE"
|
||||
exit 0
|
||||
@@ -0,0 +1,491 @@
|
||||
use chrono::{DateTime, Datelike, NaiveDateTime};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::models::deals::{Deal, DrawdownEvent, LossSequence, MonthlyPnl, PositionPair};
|
||||
use crate::models::metrics::Metrics;
|
||||
|
||||
pub struct DealAnalyzer;
|
||||
|
||||
impl DealAnalyzer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn analyze(&self, deals: &[Deal], metrics: &Metrics) -> AnalysisResult {
|
||||
let monthly = self.monthly_pnl(deals);
|
||||
let dd_events = self.reconstruct_dd_events(deals, metrics);
|
||||
let top_losses = self.top_losses(deals, 10);
|
||||
let loss_sequences = self.loss_sequences(deals);
|
||||
let pairs = self.position_pairs(deals);
|
||||
let bias = self.direction_bias(deals);
|
||||
let streak = self.streak_analysis(deals);
|
||||
let concurrent = self.concurrent_peak(deals);
|
||||
|
||||
AnalysisResult {
|
||||
monthly,
|
||||
dd_events,
|
||||
top_losses,
|
||||
loss_sequences,
|
||||
position_pairs: pairs,
|
||||
direction_bias: bias,
|
||||
streak_analysis: streak,
|
||||
concurrent_peak: concurrent,
|
||||
}
|
||||
}
|
||||
|
||||
fn monthly_pnl(&self, deals: &[Deal]) -> Vec<MonthlyPnl> {
|
||||
let mut monthly: HashMap<String, (f64, i32)> = HashMap::new();
|
||||
|
||||
for deal in deals {
|
||||
let time_str = &deal.time;
|
||||
let profit = deal.profit;
|
||||
let entry = deal.entry.to_lowercase();
|
||||
|
||||
if !entry.contains("out") && !entry.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if time_str.is_empty() || profit == 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(dt) = Self::parse_datetime(time_str) {
|
||||
let month = dt.format("%Y-%m").to_string();
|
||||
let entry = monthly.entry(month).or_insert((0.0, 0));
|
||||
entry.0 += profit;
|
||||
entry.1 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut result: Vec<MonthlyPnl> = monthly
|
||||
.into_iter()
|
||||
.map(|(m, (pnl, trades))| MonthlyPnl {
|
||||
month: m,
|
||||
pnl: (pnl * 100.0).round() / 100.0,
|
||||
trades,
|
||||
green: pnl >= 0.0,
|
||||
})
|
||||
.collect();
|
||||
|
||||
result.sort_by(|a, b| a.month.cmp(&b.month));
|
||||
result
|
||||
}
|
||||
|
||||
fn reconstruct_dd_events(&self, deals: &[Deal], _metrics: &Metrics) -> Vec<DrawdownEvent> {
|
||||
let mut balance_curve = Vec::new();
|
||||
let mut peak_balance: f64 = 0.0;
|
||||
let mut initial_balance: Option<f64> = None;
|
||||
|
||||
for deal in deals {
|
||||
let balance = deal.balance;
|
||||
if balance > 0.0 {
|
||||
if initial_balance.is_none() {
|
||||
initial_balance = Some(balance);
|
||||
}
|
||||
peak_balance = peak_balance.max(balance);
|
||||
let dd_pct = if peak_balance > 0.0 {
|
||||
(peak_balance - balance) / peak_balance * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
balance_curve.push((deal.time.clone(), balance, dd_pct, deal.profit, deal.comment.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
if balance_curve.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut events = Vec::new();
|
||||
let mut in_dd = false;
|
||||
let mut dd_start_idx = 0_usize;
|
||||
let threshold = 1.0;
|
||||
|
||||
for (i, (_, _, dd_pct, _, _)) in balance_curve.iter().enumerate() {
|
||||
if !in_dd && *dd_pct > threshold {
|
||||
in_dd = true;
|
||||
dd_start_idx = i;
|
||||
} else if in_dd && *dd_pct < threshold {
|
||||
let peak_idx = (dd_start_idx..=i)
|
||||
.max_by(|a, b| {
|
||||
let (_, _, dd_pct_a, _, _) = balance_curve[*a];
|
||||
let (_, _, dd_pct_b, _, _) = balance_curve[*b];
|
||||
dd_pct_a.partial_cmp(&dd_pct_b).unwrap()
|
||||
})
|
||||
.unwrap_or(dd_start_idx);
|
||||
|
||||
let event = self.build_dd_event(&balance_curve, dd_start_idx, peak_idx, Some(i));
|
||||
if event.peak_dd_pct > 1.0 {
|
||||
events.push(event);
|
||||
}
|
||||
in_dd = false;
|
||||
}
|
||||
}
|
||||
|
||||
if in_dd {
|
||||
let peak_idx = (dd_start_idx..balance_curve.len())
|
||||
.max_by(|a, b| {
|
||||
let (_, _, dd_pct_a, _, _) = balance_curve[*a];
|
||||
let (_, _, dd_pct_b, _, _) = balance_curve[*b];
|
||||
dd_pct_a.partial_cmp(&dd_pct_b).unwrap()
|
||||
})
|
||||
.unwrap_or(dd_start_idx);
|
||||
|
||||
let event = self.build_dd_event(&balance_curve, dd_start_idx, peak_idx, None);
|
||||
if event.peak_dd_pct > 1.0 {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
events.sort_by(|a, b| b.peak_dd_pct.partial_cmp(&a.peak_dd_pct).unwrap());
|
||||
events.truncate(10);
|
||||
events
|
||||
}
|
||||
|
||||
fn build_dd_event(&self, curve: &[(String, f64, f64, f64, String)], start_idx: usize, peak_idx: usize, recovery_idx: Option<usize>) -> DrawdownEvent {
|
||||
let (start_time, _, _, _, _) = &curve[start_idx];
|
||||
let (peak_time, _, peak_dd, _, _) = &curve[peak_idx];
|
||||
|
||||
let mut event = DrawdownEvent {
|
||||
peak_dd_pct: (*peak_dd * 100.0).round() / 100.0,
|
||||
start_date: Self::extract_date(start_time),
|
||||
end_date: Self::extract_date(peak_time),
|
||||
recovery_date: None,
|
||||
recovery_days: None,
|
||||
duration_days: 0,
|
||||
cause: "unknown".to_string(),
|
||||
};
|
||||
|
||||
if let Some(rec_idx) = recovery_idx {
|
||||
let (rec_time, _, _, _, _) = &curve[rec_idx];
|
||||
event.recovery_date = Some(Self::extract_date(rec_time));
|
||||
|
||||
if let (Ok(start_dt), Ok(rec_dt)) = (
|
||||
chrono::NaiveDate::parse_from_str(&event.start_date, "%Y-%m-%d"),
|
||||
chrono::NaiveDate::parse_from_str(event.recovery_date.as_ref().unwrap(), "%Y-%m-%d")
|
||||
) {
|
||||
event.recovery_days = Some((rec_dt - start_dt).num_days() as i32);
|
||||
}
|
||||
}
|
||||
|
||||
if let (Ok(start_dt), Ok(end_dt)) = (
|
||||
chrono::NaiveDate::parse_from_str(&event.start_date, "%Y-%m-%d"),
|
||||
chrono::NaiveDate::parse_from_str(&event.end_date, "%Y-%m-%d")
|
||||
) {
|
||||
event.duration_days = (end_dt - start_dt).num_days() as i32;
|
||||
}
|
||||
|
||||
event
|
||||
}
|
||||
|
||||
fn top_losses(&self, deals: &[Deal], n: usize) -> Vec<LossEntry> {
|
||||
let mut losses: Vec<LossEntry> = deals
|
||||
.iter()
|
||||
.filter(|d| d.profit < 0.0)
|
||||
.map(|d| LossEntry {
|
||||
date: Self::extract_date(&d.time),
|
||||
loss_usd: (d.profit * 100.0).round() / 100.0,
|
||||
comment: d.comment.clone(),
|
||||
grid_depth_at_close: self.extract_layer(&d.comment),
|
||||
volume: d.volume,
|
||||
})
|
||||
.collect();
|
||||
|
||||
losses.sort_by(|a, b| a.loss_usd.partial_cmp(&b.loss_usd).unwrap());
|
||||
losses.truncate(n);
|
||||
losses
|
||||
}
|
||||
|
||||
fn loss_sequences(&self, deals: &[Deal]) -> Vec<LossSequence> {
|
||||
let closed: Vec<&Deal> = deals
|
||||
.iter()
|
||||
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
|
||||
.collect();
|
||||
|
||||
if closed.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut sequences = Vec::new();
|
||||
let mut current_seq: Vec<&Deal> = Vec::new();
|
||||
|
||||
for deal in closed {
|
||||
if deal.profit < 0.0 {
|
||||
current_seq.push(deal);
|
||||
} else {
|
||||
if current_seq.len() >= 2 {
|
||||
let total: f64 = current_seq.iter().map(|d| d.profit).sum();
|
||||
sequences.push(LossSequence {
|
||||
length: current_seq.len() as i32,
|
||||
total_loss: (total * 100.0).round() / 100.0,
|
||||
start: Self::extract_date(¤t_seq[0].time),
|
||||
end: Self::extract_date(¤t_seq[current_seq.len() - 1].time),
|
||||
});
|
||||
}
|
||||
current_seq.clear();
|
||||
}
|
||||
}
|
||||
|
||||
if current_seq.len() >= 2 {
|
||||
let total: f64 = current_seq.iter().map(|d| d.profit).sum();
|
||||
sequences.push(LossSequence {
|
||||
length: current_seq.len() as i32,
|
||||
total_loss: (total * 100.0).round() / 100.0,
|
||||
start: Self::extract_date(¤t_seq[0].time),
|
||||
end: Self::extract_date(¤t_seq[current_seq.len() - 1].time),
|
||||
});
|
||||
}
|
||||
|
||||
sequences.sort_by(|a, b| a.total_loss.partial_cmp(&b.total_loss).unwrap());
|
||||
sequences.truncate(5);
|
||||
sequences
|
||||
}
|
||||
|
||||
fn position_pairs(&self, deals: &[Deal]) -> Vec<PositionPair> {
|
||||
let mut open_pos: HashMap<String, &Deal> = HashMap::new();
|
||||
let mut pairs = Vec::new();
|
||||
|
||||
for deal in deals {
|
||||
let order = &deal.order;
|
||||
let entry = deal.entry.to_lowercase();
|
||||
|
||||
if entry.contains("in") && !entry.contains("out") {
|
||||
open_pos.insert(order.clone(), deal);
|
||||
} else if entry.contains("out") && deal.profit != 0.0 {
|
||||
if let Some(in_deal) = open_pos.remove(order) {
|
||||
if let (Some(dt_out), Some(dt_in)) = (
|
||||
Self::parse_datetime(&deal.time),
|
||||
Self::parse_datetime(&in_deal.time)
|
||||
) {
|
||||
let hold_minutes = (dt_out - dt_in).num_seconds() as f64 / 60.0;
|
||||
|
||||
pairs.push(PositionPair {
|
||||
time: deal.time.clone(),
|
||||
deal_type: deal.deal_type.clone(),
|
||||
profit: deal.profit,
|
||||
volume: deal.volume,
|
||||
layer: self.extract_layer(&deal.comment),
|
||||
hold_minutes: Some((hold_minutes * 10.0).round() / 10.0),
|
||||
comment: deal.comment.clone(),
|
||||
magic: deal.magic.clone().unwrap_or_default(),
|
||||
order: order.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pairs
|
||||
}
|
||||
|
||||
fn direction_bias(&self, deals: &[Deal]) -> HashMap<String, DirectionStats> {
|
||||
let mut stats: HashMap<String, (i32, i32, f64)> = HashMap::new();
|
||||
stats.insert("buy".to_string(), (0, 0, 0.0));
|
||||
stats.insert("sell".to_string(), (0, 0, 0.0));
|
||||
|
||||
for deal in deals {
|
||||
let entry = deal.entry.to_lowercase();
|
||||
if !entry.contains("out") || deal.profit == 0.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let d = deal.deal_type.to_lowercase();
|
||||
if let Some((trades, wins, total_pnl)) = stats.get_mut(&d) {
|
||||
*trades += 1;
|
||||
*total_pnl += deal.profit;
|
||||
if deal.profit > 0.0 {
|
||||
*wins += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stats
|
||||
.into_iter()
|
||||
.filter(|(_, (trades, _, _))| *trades > 0)
|
||||
.map(|(d, (trades, wins, total_pnl))| {
|
||||
let avg_pnl = if trades > 0 { total_pnl / trades as f64 } else { 0.0 };
|
||||
(d, DirectionStats {
|
||||
trades,
|
||||
win_rate: ((wins as f64 / trades as f64) * 1000.0).round() / 10.0,
|
||||
total_pnl: (total_pnl * 100.0).round() / 100.0,
|
||||
avg_pnl: (avg_pnl * 100.0).round() / 100.0,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn streak_analysis(&self, deals: &[Deal]) -> StreakAnalysis {
|
||||
let closed: Vec<&Deal> = deals
|
||||
.iter()
|
||||
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
|
||||
.collect();
|
||||
|
||||
if closed.is_empty() {
|
||||
return StreakAnalysis::default();
|
||||
}
|
||||
|
||||
let (mut max_win_streak, mut max_loss_streak) = (0, 0);
|
||||
let (mut cur_win, mut cur_loss) = (0, 0);
|
||||
let (mut max_win_start, mut max_win_end) = (String::new(), String::new());
|
||||
let (mut max_loss_start, mut max_loss_end) = (String::new(), String::new());
|
||||
let (mut win_run_start, mut loss_run_start) = (String::new(), String::new());
|
||||
|
||||
for deal in closed.iter() {
|
||||
let profit = deal.profit;
|
||||
let t = Self::extract_date(&deal.time);
|
||||
|
||||
if profit > 0.0 {
|
||||
if cur_win == 0 {
|
||||
win_run_start = t.clone();
|
||||
}
|
||||
cur_win += 1;
|
||||
cur_loss = 0;
|
||||
if cur_win > max_win_streak {
|
||||
max_win_streak = cur_win;
|
||||
max_win_start = win_run_start.clone();
|
||||
max_win_end = t.clone();
|
||||
}
|
||||
} else {
|
||||
if cur_loss == 0 {
|
||||
loss_run_start = t.clone();
|
||||
}
|
||||
cur_loss += 1;
|
||||
cur_win = 0;
|
||||
if cur_loss > max_loss_streak {
|
||||
max_loss_streak = cur_loss;
|
||||
max_loss_start = loss_run_start.clone();
|
||||
max_loss_end = t.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let last = closed.last().unwrap();
|
||||
StreakAnalysis {
|
||||
max_win_streak,
|
||||
max_win_start,
|
||||
max_win_end,
|
||||
max_loss_streak,
|
||||
max_loss_start,
|
||||
max_loss_end,
|
||||
current_streak: if last.profit > 0.0 { cur_win } else { cur_loss },
|
||||
current_streak_type: if last.profit > 0.0 { "win".to_string() } else { "loss".to_string() },
|
||||
}
|
||||
}
|
||||
|
||||
fn concurrent_peak(&self, deals: &[Deal]) -> ConcurrentPeak {
|
||||
let mut events: Vec<(DateTime<chrono::Utc>, i32, &Deal)> = Vec::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") {
|
||||
events.push((dt, 1, deal));
|
||||
} else if entry.contains("out") {
|
||||
events.push((dt, -1, deal));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
events.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
let mut count = 0;
|
||||
let mut peak = 0;
|
||||
let mut peak_time = String::new();
|
||||
|
||||
for (dt, delta, deal) in events {
|
||||
count = (count + delta).max(0);
|
||||
if count > peak {
|
||||
peak = count;
|
||||
peak_time = deal.time.clone();
|
||||
}
|
||||
}
|
||||
|
||||
ConcurrentPeak { peak_open: peak, peak_time }
|
||||
}
|
||||
|
||||
fn parse_datetime(time_str: &str) -> Option<DateTime<chrono::Utc>> {
|
||||
let s = time_str.trim();
|
||||
|
||||
let formats = [
|
||||
"%Y.%m.%d %H:%M:%S",
|
||||
"%Y-%m-%d %H:%M:%S",
|
||||
"%Y.%m.%d",
|
||||
"%Y-%m-%d",
|
||||
];
|
||||
|
||||
for fmt in &formats {
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(&s[..s.len().min(19)], fmt) {
|
||||
return Some(DateTime::from_naive_utc_and_offset(dt, chrono::Utc));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_date(time_str: &str) -> String {
|
||||
if time_str.len() >= 10 {
|
||||
time_str[..10].replace('.', "-")
|
||||
} else {
|
||||
time_str.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_layer(&self, comment: &str) -> i32 {
|
||||
let re = regex::Regex::new(r"[Ll]ayer\s*#?(\d+)").ok();
|
||||
if let Some(re) = re {
|
||||
re.captures(comment)
|
||||
.and_then(|cap| cap.get(1))
|
||||
.and_then(|m| m.as_str().parse().ok())
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalysisResult {
|
||||
pub monthly: Vec<MonthlyPnl>,
|
||||
pub dd_events: Vec<DrawdownEvent>,
|
||||
pub top_losses: Vec<LossEntry>,
|
||||
pub loss_sequences: Vec<LossSequence>,
|
||||
pub position_pairs: Vec<PositionPair>,
|
||||
pub direction_bias: HashMap<String, DirectionStats>,
|
||||
pub streak_analysis: StreakAnalysis,
|
||||
pub concurrent_peak: ConcurrentPeak,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LossEntry {
|
||||
pub date: String,
|
||||
pub loss_usd: f64,
|
||||
pub comment: String,
|
||||
pub grid_depth_at_close: i32,
|
||||
pub volume: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DirectionStats {
|
||||
pub trades: i32,
|
||||
pub win_rate: f64,
|
||||
pub total_pnl: f64,
|
||||
pub avg_pnl: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct StreakAnalysis {
|
||||
pub max_win_streak: i32,
|
||||
pub max_win_start: String,
|
||||
pub max_win_end: String,
|
||||
pub max_loss_streak: i32,
|
||||
pub max_loss_start: String,
|
||||
pub max_loss_end: String,
|
||||
pub current_streak: i32,
|
||||
pub current_streak_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConcurrentPeak {
|
||||
pub peak_open: i32,
|
||||
pub peak_time: String,
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::{self, File};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::models::{Deal, Metrics};
|
||||
|
||||
pub struct ReportExtractor;
|
||||
|
||||
impl ReportExtractor {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
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)?,
|
||||
};
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_format(path: &str) -> ReportFormat {
|
||||
if path.ends_with(".xml") || path.ends_with(".htm.xml") {
|
||||
return ReportFormat::Xml;
|
||||
}
|
||||
|
||||
if let Ok(file) = fs::read(path) {
|
||||
let header = &file[..file.len().min(512)];
|
||||
if header.windows(5).any(|w| w == b"<?xml") || header.windows(8).any(|w| w == b"Workbook") {
|
||||
return ReportFormat::Xml;
|
||||
}
|
||||
}
|
||||
|
||||
ReportFormat::Html
|
||||
}
|
||||
|
||||
fn parse_html(&self, path: &str) -> Result<(Metrics, Vec<Deal>)> {
|
||||
let text = Self::read_text(path)?;
|
||||
|
||||
let metrics = Metrics::from_html(&text)
|
||||
.ok_or_else(|| anyhow!("No metrics found in HTML report"))?;
|
||||
|
||||
let deals = self.parse_deals_html(&text)?;
|
||||
|
||||
Ok((metrics, deals))
|
||||
}
|
||||
|
||||
fn parse_xml(&self, path: &str) -> Result<(Metrics, Vec<Deal>)> {
|
||||
let text = Self::read_text(path)?;
|
||||
|
||||
let metrics = Metrics::from_html(&text)
|
||||
.unwrap_or_default();
|
||||
|
||||
let deals = self.parse_deals_xml(&text)?;
|
||||
|
||||
Ok((metrics, deals))
|
||||
}
|
||||
|
||||
fn parse_deals_html(&self, text: &str) -> Result<Vec<Deal>> {
|
||||
let mut deals = Vec::new();
|
||||
|
||||
let re = regex::Regex::new(r"<tr[^>]*>.*?Deal.*?Time.*?Type.*?Direction.*?</tr>(.*)")
|
||||
.map_err(|e| anyhow!("Regex error: {}", e))?;
|
||||
|
||||
if let Some(captures) = re.captures(text) {
|
||||
let section = captures.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
|
||||
let row_re = regex::Regex::new(r"<tr[^>]*>(.*?)</tr>")
|
||||
.map_err(|e| anyhow!("Regex error: {}", e))?;
|
||||
|
||||
for row_caps in row_re.captures_iter(section) {
|
||||
let row = row_caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
|
||||
let cell_re = regex::Regex::new(r"<td[^>]*>(.*?)</td>")
|
||||
.map_err(|e| anyhow!("Regex error: {}", e))?;
|
||||
|
||||
let cells: Vec<String> = cell_re.captures_iter(row)
|
||||
.filter_map(|cap| cap.get(1))
|
||||
.map(|m| Self::strip_tags(m.as_str()))
|
||||
.map(|s| s.replace(',', ""))
|
||||
.collect();
|
||||
|
||||
if cells.len() < 3 || cells[0].is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if cells.iter().take(5).any(|c| {
|
||||
let c_lower = c.trim().to_lowercase();
|
||||
c_lower == "balance" || c_lower == "credit"
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let deal = Deal {
|
||||
time: cells.get(0).cloned().unwrap_or_default(),
|
||||
deal: cells.get(1).cloned().unwrap_or_default(),
|
||||
symbol: cells.get(2).cloned().unwrap_or_default(),
|
||||
deal_type: cells.get(3).cloned().unwrap_or_default(),
|
||||
entry: cells.get(4).cloned().unwrap_or_default(),
|
||||
volume: cells.get(5).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
price: cells.get(6).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
order: cells.get(7).cloned().unwrap_or_default(),
|
||||
commission: cells.get(8).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
swap: cells.get(9).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
profit: cells.get(10).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
balance: cells.get(11).and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
comment: cells.get(12).cloned().unwrap_or_default(),
|
||||
magic: cells.get(13).cloned(),
|
||||
};
|
||||
|
||||
deals.push(deal);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deals)
|
||||
}
|
||||
|
||||
fn parse_deals_xml(&self, text: &str) -> Result<Vec<Deal>> {
|
||||
let mut deals = Vec::new();
|
||||
let mut header_found = false;
|
||||
let mut col_map: HashMap<usize, String> = HashMap::new();
|
||||
|
||||
let row_re = regex::Regex::new(r"<Row[^>]*>(.*?)</Row>")
|
||||
.map_err(|e| anyhow!("Regex error: {}", e))?;
|
||||
|
||||
let cell_re = regex::Regex::new(r"<Cell[^>]*>.*?<Data[^>]*>(.*?)</Data>.*?</Cell>")
|
||||
.map_err(|e| anyhow!("Regex error: {}", e))?;
|
||||
|
||||
for row_caps in row_re.captures_iter(text) {
|
||||
let row = row_caps.get(1).map(|m| m.as_str()).unwrap_or("");
|
||||
|
||||
let cells: Vec<String> = cell_re.captures_iter(row)
|
||||
.filter_map(|cap| cap.get(1))
|
||||
.map(|m| Self::strip_tags(m.as_str()).replace(',', ""))
|
||||
.collect();
|
||||
|
||||
if !header_found {
|
||||
let row_str = cells.join("").to_lowercase();
|
||||
if row_str.contains("time") || row_str.contains("type") || row_str.contains("volume") {
|
||||
header_found = true;
|
||||
for (i, h) in cells.iter().enumerate() {
|
||||
let h_lower = h.to_lowercase().trim().to_string();
|
||||
let deal_columns = ["time", "deal", "symbol", "type", "entry", "volume", "price", "order", "commission", "swap", "profit", "balance", "comment"];
|
||||
for col in &deal_columns {
|
||||
if h_lower.contains(col) || col.contains(&h_lower) {
|
||||
col_map.insert(i, col.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if cells.is_empty() || cells[0].is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut deal_map: HashMap<String, String> = HashMap::new();
|
||||
for (i, val) in cells.iter().enumerate() {
|
||||
if let Some(col) = col_map.get(&i) {
|
||||
deal_map.insert(col.clone(), val.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !deal_map.is_empty() {
|
||||
let deal = Deal {
|
||||
time: deal_map.get("time").cloned().unwrap_or_default(),
|
||||
deal: deal_map.get("deal").cloned().unwrap_or_default(),
|
||||
symbol: deal_map.get("symbol").cloned().unwrap_or_default(),
|
||||
deal_type: deal_map.get("type").cloned().unwrap_or_default(),
|
||||
entry: deal_map.get("entry").cloned().unwrap_or_default(),
|
||||
volume: deal_map.get("volume").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
price: deal_map.get("price").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
order: deal_map.get("order").cloned().unwrap_or_default(),
|
||||
commission: deal_map.get("commission").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
swap: deal_map.get("swap").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
profit: deal_map.get("profit").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
balance: deal_map.get("balance").and_then(|s| s.parse().ok()).unwrap_or(0.0),
|
||||
comment: deal_map.get("comment").cloned().unwrap_or_default(),
|
||||
magic: deal_map.get("magic").cloned(),
|
||||
};
|
||||
deals.push(deal);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deals)
|
||||
}
|
||||
|
||||
fn write_metrics(&self, metrics: &Metrics, path: &Path) -> Result<()> {
|
||||
let json = serde_json::to_string_pretty(metrics)?;
|
||||
let mut file = File::create(path)?;
|
||||
file.write_all(json.as_bytes())?;
|
||||
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")?;
|
||||
|
||||
for deal in deals {
|
||||
writeln!(file, "{},{},{},{},{},{},{},{},{},{},{},{},\"{}\"",
|
||||
deal.time,
|
||||
deal.deal,
|
||||
deal.symbol,
|
||||
deal.deal_type,
|
||||
deal.entry,
|
||||
deal.volume,
|
||||
deal.price,
|
||||
deal.order,
|
||||
deal.commission,
|
||||
deal.swap,
|
||||
deal.profit,
|
||||
deal.balance,
|
||||
deal.comment.replace('"', "\"\"")
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_text(path: &str) -> Result<String> {
|
||||
let raw = fs::read(path)?;
|
||||
|
||||
if raw.starts_with(&[0xFF, 0xFE]) || raw.starts_with(&[0xFE, 0xFF]) {
|
||||
// UTF-16 BOM
|
||||
let text = String::from_utf16_lossy(
|
||||
raw.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice()
|
||||
);
|
||||
return Ok(text);
|
||||
}
|
||||
|
||||
if let Ok(text) = String::from_utf8(raw.clone()) {
|
||||
return Ok(text);
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&raw).to_string())
|
||||
}
|
||||
|
||||
fn strip_tags(html: &str) -> String {
|
||||
let re = regex::Regex::new(r"<[^>]+>").unwrap();
|
||||
re.replace_all(html, "").trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExtractionResult {
|
||||
pub metrics: Metrics,
|
||||
pub deals: Vec<Deal>,
|
||||
pub metrics_path: PathBuf,
|
||||
pub deals_csv_path: PathBuf,
|
||||
pub deals_json_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum ReportFormat {
|
||||
Html,
|
||||
Xml,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod extract;
|
||||
pub mod analyze;
|
||||
|
||||
pub use extract::ReportExtractor;
|
||||
pub use analyze::DealAnalyzer;
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod mql_compiler;
|
||||
|
||||
pub use mql_compiler::MqlCompiler;
|
||||
@@ -0,0 +1,226 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use crate::models::Config;
|
||||
|
||||
pub struct MqlCompiler {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
pub struct CompileResult {
|
||||
pub success: bool,
|
||||
pub ex5_path: Option<PathBuf>,
|
||||
pub errors: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
pub binary_size: u64,
|
||||
}
|
||||
|
||||
impl MqlCompiler {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub fn compile(&self, source_path: &str) -> Result<CompileResult> {
|
||||
let source_path = Path::new(source_path);
|
||||
|
||||
if !source_path.exists() {
|
||||
return Err(anyhow!("Source file not found: {}", source_path.display()));
|
||||
}
|
||||
|
||||
let expert_name = source_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| anyhow!("Invalid source file name"))?;
|
||||
|
||||
let mt5_dir = self.config.mt5_dir()
|
||||
.ok_or_else(|| anyhow!("terminal_dir not configured"))?;
|
||||
|
||||
let metaeditor = mt5_dir.join("metaeditor64.exe");
|
||||
if !metaeditor.exists() {
|
||||
return Err(anyhow!("metaeditor64.exe not found at: {}", metaeditor.display()));
|
||||
}
|
||||
|
||||
let mt5_src_dir = mt5_dir.join("MQL5").join("Experts");
|
||||
fs::create_dir_all(&mt5_src_dir)?;
|
||||
|
||||
let dest_path = mt5_src_dir.join(format!("{}.mq5", expert_name));
|
||||
fs::copy(source_path, &dest_path)?;
|
||||
|
||||
self.sync_include_files(source_path, &mt5_dir)?;
|
||||
|
||||
let wine_prefix = self.get_wine_prefix(&mt5_dir)?;
|
||||
let wine_exe = self.config.wine_executable.as_ref()
|
||||
.ok_or_else(|| anyhow!("wine_executable not configured"))?;
|
||||
|
||||
let log_file = tempfile::NamedTempFile::new()?.path().to_path_buf();
|
||||
let wine_src_path = Self::host_to_wine_path(&dest_path)?;
|
||||
let wine_log_path = Self::host_to_wine_path(&log_file)?;
|
||||
|
||||
let output = Command::new(wine_exe)
|
||||
.arg(&metaeditor)
|
||||
.arg(format!("/compile:{}", wine_src_path))
|
||||
.arg(format!("/log:{}", wine_log_path))
|
||||
.env("WINEPREFIX", &wine_prefix)
|
||||
.env("WINEDEBUG", "-all")
|
||||
.output()?;
|
||||
|
||||
let log_content = if log_file.exists() {
|
||||
fs::read_to_string(&log_file).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let errors: Vec<String> = log_content
|
||||
.lines()
|
||||
.filter(|l| l.to_lowercase().contains("error"))
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let warnings: Vec<String> = log_content
|
||||
.lines()
|
||||
.filter(|l| l.to_lowercase().contains("warning"))
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let ex5_path = mt5_src_dir.join(format!("{}.ex5", expert_name));
|
||||
|
||||
if !ex5_path.exists() {
|
||||
return Ok(CompileResult {
|
||||
success: false,
|
||||
ex5_path: None,
|
||||
errors,
|
||||
warnings,
|
||||
binary_size: 0,
|
||||
});
|
||||
}
|
||||
|
||||
let binary_size = fs::metadata(&ex5_path)?.len();
|
||||
|
||||
Ok(CompileResult {
|
||||
success: errors.is_empty(),
|
||||
ex5_path: Some(ex5_path),
|
||||
errors,
|
||||
warnings,
|
||||
binary_size,
|
||||
})
|
||||
}
|
||||
|
||||
fn sync_include_files(&self, source_path: &Path, mt5_dir: &Path) -> Result<()> {
|
||||
let source_dir = source_path.parent()
|
||||
.ok_or_else(|| anyhow!("Source path has no parent"))?;
|
||||
|
||||
if let Some(include_dir) = Self::find_include_dir(source_dir) {
|
||||
let mt5_include = mt5_dir.join("MQL5").join("Include");
|
||||
fs::create_dir_all(&mt5_include)?;
|
||||
|
||||
let mut synced_total = 0;
|
||||
|
||||
for entry in fs::read_dir(&include_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
let dir_name = path.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.ok_or_else(|| anyhow!("Invalid directory name"))?;
|
||||
|
||||
let mt5_dest = mt5_include.join(dir_name);
|
||||
if mt5_dest.exists() {
|
||||
fs::remove_dir_all(&mt5_dest)?;
|
||||
}
|
||||
|
||||
Self::copy_dir_all(&path, &mt5_dest)?;
|
||||
|
||||
let count = Self::count_mqh_files(&mt5_dest)?;
|
||||
if count > 0 {
|
||||
synced_total += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(&include_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().map(|e| e == "mqh").unwrap_or(false) {
|
||||
let dest = mt5_include.join(path.file_name().unwrap());
|
||||
fs::copy(&path, &dest)?;
|
||||
synced_total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if synced_total > 0 {
|
||||
tracing::info!("Synced {} .mqh file(s)", synced_total);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_include_dir(source_dir: &Path) -> Option<PathBuf> {
|
||||
let candidates = [
|
||||
source_dir.join("include"),
|
||||
source_dir.parent().map(|p| p.join("include")).unwrap_or_default(),
|
||||
source_dir.parent().and_then(|p| p.parent()).map(|p| p.join("include")).unwrap_or_default(),
|
||||
];
|
||||
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() && candidate.is_dir() {
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn count_mqh_files(dir: &Path) -> Result<usize> {
|
||||
let mut count = 0;
|
||||
for entry in walkdir::WalkDir::new(dir) {
|
||||
let entry = entry?;
|
||||
if entry.path().extension().map(|e| e == "mqh").unwrap_or(false) {
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
|
||||
fs::create_dir_all(dst)?;
|
||||
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let dest = dst.join(entry.file_name());
|
||||
|
||||
if path.is_dir() {
|
||||
Self::copy_dir_all(&path, &dest)?;
|
||||
} else {
|
||||
fs::copy(&path, &dest)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn host_to_wine_path(host_path: &Path) -> Result<String> {
|
||||
let abs_path = host_path.canonicalize()?;
|
||||
let path_str = abs_path.to_string_lossy();
|
||||
|
||||
if path_str.starts_with('/') {
|
||||
let wine_path = path_str.replace('/', "\\\\");
|
||||
Ok(format!("C:\\{}", &wine_path[1..]))
|
||||
} else {
|
||||
Ok(path_str.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn get_wine_prefix(&self, mt5_dir: &Path) -> Result<PathBuf> {
|
||||
mt5_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.ok_or_else(|| anyhow!("Could not determine Wine prefix from MT5 directory"))
|
||||
}
|
||||
}
|
||||
+6
-90
@@ -1,6 +1,11 @@
|
||||
mod analytics;
|
||||
mod compile;
|
||||
mod models;
|
||||
mod pipeline;
|
||||
mod tools;
|
||||
|
||||
mod config;
|
||||
mod mcp_server;
|
||||
mod mt5;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
@@ -192,92 +197,3 @@ async fn handle_connection(socket: tokio::net::TcpStream) -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_tools_list() -> Value {
|
||||
json!([
|
||||
{
|
||||
"name": "verify_setup",
|
||||
"description": "Verify MT5-Quant environment without launching MT5. Checks Wine executable, MT5 installation paths, and config file.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_symbols",
|
||||
"description": "Detect the active MT5 broker session and list symbols that have local tick history available for backtesting.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": {
|
||||
"type": "string",
|
||||
"description": "Filter to a specific server name. If omitted, shows active server and all servers."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_experts",
|
||||
"description": "List all compiled Expert Advisors (.ex5 files) found in the MT5 Experts directory.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {
|
||||
"type": "string",
|
||||
"description": "Optional substring filter on EA name (case-insensitive)."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run_backtest",
|
||||
"description": "Run a complete MT5 backtest pipeline.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert"],
|
||||
"properties": {
|
||||
"expert": {
|
||||
"type": "string",
|
||||
"description": "EA name without path or extension. e.g. 'MyEA_v1.2'"
|
||||
},
|
||||
"symbol": {
|
||||
"type": "string",
|
||||
"description": "Trading symbol. Use your broker's exact name. e.g. 'XAUUSD'"
|
||||
},
|
||||
"from_date": {
|
||||
"type": "string",
|
||||
"description": "Start date in YYYY.MM.DD format"
|
||||
},
|
||||
"to_date": {
|
||||
"type": "string",
|
||||
"description": "End date in YYYY.MM.DD format"
|
||||
},
|
||||
"timeframe": {
|
||||
"type": "string",
|
||||
"enum": ["M1", "M5", "M15", "M30", "H1", "H4", "D1"],
|
||||
"description": "Chart timeframe (default: M5)"
|
||||
},
|
||||
"deposit": {
|
||||
"type": "integer",
|
||||
"description": "Initial deposit (default: from config)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "compile_ea",
|
||||
"description": "Compile an MQL5 Expert Advisor via MetaEditor (Wine/CrossOver).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert_path"],
|
||||
"properties": {
|
||||
"expert_path": {
|
||||
"type": "string",
|
||||
"description": "Path to .mq5 source file"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
+12
-66
@@ -2,20 +2,20 @@ use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{config::Config, mt5::Mt5Manager, McpError, McpRequest, McpResponse};
|
||||
use crate::{models::Config as ModelsConfig, tools::ToolHandler, McpError, McpRequest, McpResponse};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct McpServer {
|
||||
initialized: Arc<Mutex<bool>>,
|
||||
mt5_manager: Arc<Mt5Manager>,
|
||||
tool_handler: Arc<ToolHandler>,
|
||||
}
|
||||
|
||||
impl McpServer {
|
||||
pub fn new() -> Self {
|
||||
let config = Config::load().unwrap_or_default();
|
||||
let config = ModelsConfig::load().unwrap_or_default();
|
||||
Self {
|
||||
initialized: Arc::new(Mutex::new(false)),
|
||||
mt5_manager: Arc::new(Mt5Manager::new(config)),
|
||||
tool_handler: Arc::new(ToolHandler::new(config)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ impl McpServer {
|
||||
McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: request.id,
|
||||
result: Some(crate::get_tools_list()),
|
||||
result: Some(crate::tools::get_tools_list()),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
@@ -132,66 +132,12 @@ impl McpServer {
|
||||
}
|
||||
|
||||
async fn handle_tool_call(&self, tool_name: &str, arguments: &Value) -> Value {
|
||||
match tool_name {
|
||||
"verify_setup" => {
|
||||
self.mt5_manager.verify_setup().await.unwrap_or_else(|e| json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Setup verification failed: {}", e)
|
||||
}],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
"list_symbols" => {
|
||||
self.mt5_manager.list_symbols().await.unwrap_or_else(|e| json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Failed to list symbols: {}", e)
|
||||
}],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
"list_experts" => {
|
||||
let filter = arguments.get("filter").and_then(|v| v.as_str());
|
||||
self.mt5_manager.list_experts(filter).await.unwrap_or_else(|e| json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Failed to list experts: {}", e)
|
||||
}],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
"run_backtest" => {
|
||||
self.mt5_manager.run_backtest(arguments).await.unwrap_or_else(|e| json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Backtest failed: {}", e)
|
||||
}],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
"compile_ea" => {
|
||||
let expert_path = arguments.get("expert_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
self.mt5_manager.compile_ea(expert_path).await.unwrap_or_else(|e| json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Compilation failed: {}", e)
|
||||
}],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
_ => {
|
||||
json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Tool '{}' not found", tool_name)
|
||||
}],
|
||||
"isError": true
|
||||
})
|
||||
}
|
||||
}
|
||||
self.tool_handler.handle(tool_name, arguments).await.unwrap_or_else(|e| json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Tool execution failed: {}", e)
|
||||
}],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
pub wine_executable: Option<String>,
|
||||
pub terminal_dir: Option<String>,
|
||||
pub experts_dir: Option<String>,
|
||||
pub tester_profiles_dir: Option<String>,
|
||||
pub tester_cache_dir: Option<String>,
|
||||
pub display_mode: Option<String>,
|
||||
pub backtest_symbol: Option<String>,
|
||||
pub backtest_deposit: Option<u32>,
|
||||
pub backtest_currency: Option<String>,
|
||||
pub backtest_leverage: Option<u32>,
|
||||
pub backtest_model: Option<u32>,
|
||||
pub backtest_timeframe: Option<String>,
|
||||
pub backtest_timeout: Option<u32>,
|
||||
pub opt_log_dir: Option<String>,
|
||||
pub opt_min_agents: Option<u32>,
|
||||
pub reports_dir: Option<String>,
|
||||
pub backtest_login: Option<String>,
|
||||
pub backtest_server: Option<String>,
|
||||
pub project_dir: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wine_executable: None,
|
||||
terminal_dir: None,
|
||||
experts_dir: None,
|
||||
tester_profiles_dir: None,
|
||||
tester_cache_dir: None,
|
||||
display_mode: None,
|
||||
backtest_symbol: None,
|
||||
backtest_deposit: None,
|
||||
backtest_currency: None,
|
||||
backtest_leverage: None,
|
||||
backtest_model: None,
|
||||
backtest_timeframe: None,
|
||||
backtest_timeout: None,
|
||||
opt_log_dir: None,
|
||||
opt_min_agents: None,
|
||||
reports_dir: None,
|
||||
backtest_login: None,
|
||||
backtest_server: None,
|
||||
project_dir: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let config_path = Self::get_config_path();
|
||||
if !config_path.exists() {
|
||||
return Ok(Config::default());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&config_path)?;
|
||||
let mut config: HashMap<String, String> = HashMap::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
if line.starts_with('#') || !line.contains(':') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
let key = key.trim().to_string();
|
||||
let value = value.trim()
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.to_string();
|
||||
|
||||
if !value.is_empty() && value != "null" && value != "~" {
|
||||
config.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Config {
|
||||
wine_executable: config.get("wine_executable").cloned(),
|
||||
terminal_dir: config.get("terminal_dir").cloned(),
|
||||
experts_dir: config.get("experts_dir").cloned(),
|
||||
tester_profiles_dir: config.get("tester_profiles_dir").cloned(),
|
||||
tester_cache_dir: config.get("tester_cache_dir").cloned(),
|
||||
display_mode: config.get("display_mode").cloned(),
|
||||
backtest_symbol: config.get("backtest_symbol").cloned(),
|
||||
backtest_deposit: config.get("backtest_deposit").and_then(|s| s.parse().ok()),
|
||||
backtest_currency: config.get("backtest_currency").cloned(),
|
||||
backtest_leverage: config.get("backtest_leverage").and_then(|s| s.parse().ok()),
|
||||
backtest_model: config.get("backtest_model").and_then(|s| s.parse().ok()),
|
||||
backtest_timeframe: config.get("backtest_timeframe").cloned(),
|
||||
backtest_timeout: config.get("backtest_timeout").and_then(|s| s.parse().ok()),
|
||||
opt_log_dir: config.get("opt_log_dir").cloned(),
|
||||
opt_min_agents: config.get("opt_min_agents").and_then(|s| s.parse().ok()),
|
||||
reports_dir: config.get("reports_dir").cloned(),
|
||||
backtest_login: config.get("backtest_login").cloned(),
|
||||
backtest_server: config.get("backtest_server").cloned(),
|
||||
project_dir: config.get("project_dir").cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_config_path() -> std::path::PathBuf {
|
||||
if let Ok(home) = std::env::var("MT5_MCP_HOME") {
|
||||
Path::new(&home).join("config").join("mt5-quant.yaml")
|
||||
} else {
|
||||
let base_path = dirs::home_dir()
|
||||
.unwrap_or_else(|| Path::new(".").to_path_buf())
|
||||
.join(".config")
|
||||
.join("mt5-quant");
|
||||
|
||||
if base_path.join("config").join("mt5-quant.yaml").exists() {
|
||||
base_path.join("config").join("mt5-quant.yaml")
|
||||
} else {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.join("config")
|
||||
.join("mt5-quant.yaml")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> String {
|
||||
match key {
|
||||
"wine_executable" => self.wine_executable.clone().unwrap_or_default(),
|
||||
"terminal_dir" => self.terminal_dir.clone().unwrap_or_default(),
|
||||
"experts_dir" => self.experts_dir.clone().unwrap_or_default(),
|
||||
"tester_profiles_dir" => self.tester_profiles_dir.clone().unwrap_or_default(),
|
||||
"tester_cache_dir" => self.tester_cache_dir.clone().unwrap_or_default(),
|
||||
"display_mode" => self.display_mode.clone().unwrap_or_else(|| "auto".to_string()),
|
||||
"backtest_symbol" => self.backtest_symbol.clone().unwrap_or_default(),
|
||||
"backtest_deposit" => self.backtest_deposit.unwrap_or(10000).to_string(),
|
||||
"backtest_currency" => self.backtest_currency.clone().unwrap_or_else(|| "USD".to_string()),
|
||||
"backtest_leverage" => self.backtest_leverage.unwrap_or(500).to_string(),
|
||||
"backtest_model" => self.backtest_model.unwrap_or(0).to_string(),
|
||||
"backtest_timeframe" => self.backtest_timeframe.clone().unwrap_or_else(|| "M5".to_string()),
|
||||
"backtest_timeout" => self.backtest_timeout.unwrap_or(900).to_string(),
|
||||
"opt_log_dir" => self.opt_log_dir.clone().unwrap_or_else(|| "/tmp".to_string()),
|
||||
"opt_min_agents" => self.opt_min_agents.unwrap_or(1).to_string(),
|
||||
"reports_dir" => self.reports_dir.clone().unwrap_or_else(|| "reports".to_string()),
|
||||
"backtest_login" => self.backtest_login.clone().unwrap_or_default(),
|
||||
"backtest_server" => self.backtest_server.clone().unwrap_or_default(),
|
||||
"project_dir" => self.project_dir.clone().unwrap_or_default(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reports_dir(&self) -> std::path::PathBuf {
|
||||
Path::new(&self.get("reports_dir")).to_path_buf()
|
||||
}
|
||||
|
||||
pub fn mt5_dir(&self) -> Option<std::path::PathBuf> {
|
||||
self.terminal_dir.as_ref().map(|d| Path::new(d).to_path_buf())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Deal {
|
||||
pub time: String,
|
||||
pub deal: String,
|
||||
pub symbol: String,
|
||||
#[serde(rename = "type")]
|
||||
pub deal_type: String,
|
||||
pub entry: String,
|
||||
pub volume: f64,
|
||||
pub price: f64,
|
||||
pub order: String,
|
||||
pub commission: f64,
|
||||
pub swap: f64,
|
||||
pub profit: f64,
|
||||
pub balance: f64,
|
||||
pub comment: String,
|
||||
pub magic: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DealType {
|
||||
Buy,
|
||||
Sell,
|
||||
Balance,
|
||||
Credit,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PositionPair {
|
||||
pub time: String,
|
||||
pub deal_type: String,
|
||||
pub profit: f64,
|
||||
pub volume: f64,
|
||||
pub layer: i32,
|
||||
pub hold_minutes: Option<f64>,
|
||||
pub comment: String,
|
||||
pub magic: String,
|
||||
pub order: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DrawdownEvent {
|
||||
pub peak_dd_pct: f64,
|
||||
pub start_date: String,
|
||||
pub end_date: String,
|
||||
pub recovery_date: Option<String>,
|
||||
pub recovery_days: Option<i32>,
|
||||
pub duration_days: i32,
|
||||
pub cause: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MonthlyPnl {
|
||||
pub month: String,
|
||||
pub pnl: f64,
|
||||
pub trades: i32,
|
||||
pub green: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LossSequence {
|
||||
pub length: i32,
|
||||
pub total_loss: f64,
|
||||
pub start: String,
|
||||
pub end: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CycleStats {
|
||||
pub total_cycles: i32,
|
||||
pub win_rate: f64,
|
||||
pub avg_profit: f64,
|
||||
pub win_rate_by_depth: HashMap<String, WinRateByDepth>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WinRateByDepth {
|
||||
pub total: i32,
|
||||
pub win_rate: f64,
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Metrics {
|
||||
pub net_profit: f64,
|
||||
pub profit_factor: f64,
|
||||
pub max_dd_pct: f64,
|
||||
pub sharpe_ratio: f64,
|
||||
pub total_trades: i32,
|
||||
pub recovery_factor: f64,
|
||||
pub win_rate_pct: f64,
|
||||
pub gross_profit: f64,
|
||||
pub gross_loss: f64,
|
||||
}
|
||||
|
||||
impl Metrics {
|
||||
pub fn from_html(text: &str) -> Option<Self> {
|
||||
let mut m = Metrics::default();
|
||||
|
||||
let patterns = [
|
||||
("net_profit", r"Net\s+Profit[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
("profit_factor", r"Profit\s+Factor[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
("max_dd_pct", r"Equity\s+Drawdown\s+Maximal[^<]*</td>\s*<td[^>]*>\s*<b>[^(]*\(([\d.,]+)%\)"),
|
||||
("sharpe_ratio", r"Sharpe\s+Ratio[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
("total_trades", r"Total\s+Trades[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
("recovery_factor", r"Recovery\s+Factor[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
("win_rate_pct", r"Profit\s+Trades\s+\(%[^<]*</td>\s*<td[^>]*>\s*<b>[^(]*\(([\d.,]+)%\)"),
|
||||
("gross_profit", r"Gross\s+Profit[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
("gross_loss", r"Gross\s+Loss[^<]*</td>\s*<td[^>]*>\s*<b>([-\d\s.,]+)</b>"),
|
||||
];
|
||||
|
||||
for (key, pattern) in &patterns {
|
||||
if let Ok(regex) = regex::Regex::new(pattern) {
|
||||
if let Some(captures) = regex.captures(text) {
|
||||
if let Some(val_str) = captures.get(1) {
|
||||
let val = val_str.as_str()
|
||||
.replace(' ', "")
|
||||
.replace(',', "");
|
||||
|
||||
match *key {
|
||||
"net_profit" => m.net_profit = val.parse().unwrap_or(0.0),
|
||||
"profit_factor" => m.profit_factor = val.parse().unwrap_or(0.0),
|
||||
"max_dd_pct" => m.max_dd_pct = val.parse().unwrap_or(0.0),
|
||||
"sharpe_ratio" => m.sharpe_ratio = val.parse().unwrap_or(0.0),
|
||||
"total_trades" => m.total_trades = val.parse().unwrap_or(0) as i32,
|
||||
"recovery_factor" => m.recovery_factor = val.parse().unwrap_or(0.0),
|
||||
"win_rate_pct" => m.win_rate_pct = val.parse().unwrap_or(0.0),
|
||||
"gross_profit" => m.gross_profit = val.parse().unwrap_or(0.0),
|
||||
"gross_loss" => m.gross_loss = val.parse().unwrap_or(0.0),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.total_trades > 0 {
|
||||
Some(m)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
pub mod config;
|
||||
pub mod deals;
|
||||
pub mod metrics;
|
||||
pub mod report;
|
||||
|
||||
pub use config::Config;
|
||||
pub use deals::Deal;
|
||||
pub use metrics::Metrics;
|
||||
@@ -0,0 +1,60 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Report {
|
||||
pub report_dir: PathBuf,
|
||||
pub expert: String,
|
||||
pub symbol: String,
|
||||
pub timeframe: String,
|
||||
pub from_date: String,
|
||||
pub to_date: String,
|
||||
pub metrics_file: PathBuf,
|
||||
pub deals_csv: PathBuf,
|
||||
pub deals_json: PathBuf,
|
||||
pub analysis_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PipelineMetadata {
|
||||
pub expert: String,
|
||||
pub symbol: String,
|
||||
pub timeframe: String,
|
||||
pub from_date: String,
|
||||
pub to_date: String,
|
||||
pub deposit: f64,
|
||||
pub currency: String,
|
||||
pub model: i32,
|
||||
pub leverage: i32,
|
||||
pub set_file: Option<String>,
|
||||
pub report_dir: String,
|
||||
pub duration_seconds: i64,
|
||||
pub files: FilePaths,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FilePaths {
|
||||
pub metrics: String,
|
||||
pub analysis: String,
|
||||
pub deals_csv: String,
|
||||
pub deals_json: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct BacktestStatus {
|
||||
pub stage: PipelineStage,
|
||||
pub elapsed_seconds: i64,
|
||||
pub is_complete: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum PipelineStage {
|
||||
Compile,
|
||||
Clean,
|
||||
Backtest,
|
||||
Extract,
|
||||
Analyze,
|
||||
Done,
|
||||
}
|
||||
+551
-36
@@ -1,6 +1,6 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use serde_json::json;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -15,6 +15,17 @@ pub struct Mt5Manager {
|
||||
}
|
||||
|
||||
impl Mt5Manager {
|
||||
// Helper function to handle quoted strings
|
||||
fn trim_quotes(s: &str) -> String {
|
||||
let quoted = s.trim()
|
||||
.trim_start_matches('"')
|
||||
.trim_end_matches('"');
|
||||
if quoted.starts_with('"') && quoted.ends_with('"') {
|
||||
quoted[1..quoted.len()-1].to_string()
|
||||
} else {
|
||||
quoted.to_string()
|
||||
}
|
||||
}
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
@@ -237,35 +248,55 @@ impl Mt5Manager {
|
||||
}
|
||||
|
||||
pub async fn run_backtest(&self, params: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
let expert = params.get("expert")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow!("expert parameter is required"))?;
|
||||
let ea = params.get("ea")
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.ok_or_else(|| anyhow!("ea parameter is required"))?;
|
||||
|
||||
let symbol = params.get("symbol")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.unwrap_or("XAUUSD");
|
||||
|
||||
let timeframe = params.get("timeframe")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.unwrap_or("M5");
|
||||
|
||||
let deposit = params.get("deposit")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(10000);
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.unwrap_or("10000");
|
||||
|
||||
let from_date = params.get("from_date")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.ok_or_else(|| anyhow!("from_date parameter is required"))?;
|
||||
|
||||
let to_date = params.get("to_date")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.ok_or_else(|| anyhow!("to_date parameter is required"))?;
|
||||
|
||||
let _tag = params.get("tag")
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let _verdict = params.get("verdict")
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let _sort_by = params.get("sort_by")
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let _limit = params.get("limit")
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.unwrap_or("20");
|
||||
|
||||
let _include_monthly = params.get("include_monthly")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Create report directory
|
||||
let report_name = format!(
|
||||
"{}_{}_{}",
|
||||
chrono::Utc::now().format("%Y%m%d_%H%M%S"),
|
||||
expert,
|
||||
ea,
|
||||
symbol
|
||||
);
|
||||
let report_dir = Path::new(&self.config.get("reports_dir"))
|
||||
@@ -285,7 +316,7 @@ impl Mt5Manager {
|
||||
&format!("/deposit:{}", deposit),
|
||||
&format!("/fromdate:{}", from_date),
|
||||
&format!("/todate:{}", to_date),
|
||||
&format!("/expert:{}", expert),
|
||||
&format!("/expert:{}", ea),
|
||||
&format!("/report:{}", report_dir.to_string_lossy()),
|
||||
"/skipupdate",
|
||||
"/quiet",
|
||||
@@ -307,37 +338,521 @@ impl Mt5Manager {
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn compile_ea(&self, expert_path: &str) -> Result<serde_json::Value> {
|
||||
let path = Path::new(expert_path);
|
||||
if !path.exists() {
|
||||
return Err(anyhow!("Expert file not found: {}", expert_path));
|
||||
pub async fn compare_baseline(&self, arguments: &Value) -> Result<serde_json::Value> {
|
||||
if let Some(baseline) = arguments.get("baseline") {
|
||||
let baseline: &serde_json::Value = baseline;
|
||||
let net_profit = baseline.get("net_profit")
|
||||
.and_then(|v: &serde_json::Value| v.as_f64())
|
||||
.ok_or_else(|| anyhow!("baseline.net_profit is required"))?;
|
||||
|
||||
let max_dd_pct = baseline.get("max_dd_pct")
|
||||
.and_then(|v: &serde_json::Value| v.as_f64())
|
||||
.ok_or_else(|| anyhow!("baseline.max_dd_pct is required"))?;
|
||||
|
||||
let _total_trades = baseline.get("total_trades")
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.ok_or_else(|| anyhow!("baseline.total_trades is required"))?;
|
||||
|
||||
let promote_dd_limit = arguments.get("promote_dd_limit")
|
||||
.and_then(|v: &serde_json::Value| v.as_f64())
|
||||
.unwrap_or(20.0);
|
||||
|
||||
let report_dir = arguments.get("report_dir")
|
||||
.and_then(|v: &serde_json::Value| v.as_str())
|
||||
.unwrap_or("latest");
|
||||
|
||||
// Simplified comparison logic
|
||||
let verdict = if net_profit > 0.0 && max_dd_pct < promote_dd_limit {
|
||||
"winner"
|
||||
} else if net_profit.abs() < 100.0 {
|
||||
"loser"
|
||||
} else {
|
||||
"marginal"
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"report_dir": report_dir,
|
||||
"baseline": baseline,
|
||||
"verdict": verdict,
|
||||
"promote_dd_limit": promote_dd_limit
|
||||
}))
|
||||
} else {
|
||||
Err(anyhow!("baseline argument is required"))
|
||||
}
|
||||
}
|
||||
|
||||
let extension = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if extension != "mq5" {
|
||||
return Err(anyhow!("File must have .mq5 extension"));
|
||||
}
|
||||
pub async fn read_set_file(&self, path: &str) -> Result<serde_json::Value> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
|
||||
// Parse simple YAML-like format (simplified)
|
||||
let mut params = serde_json::Map::new();
|
||||
for line in content.lines() {
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
let key = key.trim().to_string();
|
||||
let value = value.trim();
|
||||
let clean_value = if value.starts_with('"') && value.ends_with('"') {
|
||||
value[1..value.len()-1].to_string()
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
|
||||
let is_opt = value.contains("||Y");
|
||||
let clean_value = clean_value.replace("||Y", "");
|
||||
|
||||
if let Ok(num_val) = clean_value.parse::<f64>() {
|
||||
params.insert(key.clone(), json!(num_val));
|
||||
} else if let Ok(bool_val) = clean_value.parse::<bool>() {
|
||||
params.insert(key.clone(), json!(bool_val));
|
||||
} else {
|
||||
params.insert(key.clone(), json!(value));
|
||||
}
|
||||
|
||||
// Build MetaEditor command
|
||||
let mut cmd = AsyncCommand::new(&self.config.wine_executable.as_ref().unwrap());
|
||||
cmd.args([
|
||||
"metaeditor64.exe",
|
||||
&format!("/compile:{}", expert_path),
|
||||
"/close",
|
||||
]);
|
||||
|
||||
cmd.current_dir(Path::new(&self.config.terminal_dir.as_ref().unwrap()));
|
||||
|
||||
let output = cmd.output().await?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow!("Compilation failed: {}", String::from_utf8_lossy(&output.stderr)));
|
||||
if is_opt {
|
||||
params.insert(format!("{}_optimize", key), json!(true));
|
||||
if let Some((from_val, to_val)) = clean_value.split_once("..") {
|
||||
params.insert(format!("{}_from", key), json!(from_val.trim()));
|
||||
params.insert(format!("{}_to", key), json!(to_val.trim()));
|
||||
params.insert(format!("{}_step", key), json!(1.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"message": "Expert compiled successfully",
|
||||
"expert_path": expert_path
|
||||
"path": path,
|
||||
"params": params
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn write_set_file(&self, path: &str, params: &serde_json::Map<String, serde_json::Value>) -> Result<serde_json::Value> {
|
||||
// Convert params back to YAML-like format
|
||||
let mut content = String::new();
|
||||
for (key, value) in params {
|
||||
let param_key = key.replace("_optimize", "").replace("_from", "").replace("_to", "").replace("_step", "");
|
||||
|
||||
match value {
|
||||
serde_json::Value::Number(n) => {
|
||||
content.push_str(&format!("{}: {}\n", param_key, n));
|
||||
}
|
||||
serde_json::Value::Bool(b) => {
|
||||
content.push_str(&format!("{}: {}\n", param_key, b));
|
||||
}
|
||||
serde_json::Value::String(s) => {
|
||||
let s = s.as_str();
|
||||
if param_key.contains("optimize") && s == "true" {
|
||||
content.push_str(&format!("{}: ||Y\n", param_key));
|
||||
} else if param_key.contains("from") || param_key.contains("to") || param_key.contains("step") {
|
||||
content.push_str(&format!("{}: {}\n", param_key, s));
|
||||
} else {
|
||||
content.push_str(&format!("{}: \"{}\"\n", param_key, s));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(path, content)?;
|
||||
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"path": path,
|
||||
"message": "Set file written successfully"
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn clone_set_file(&self, source: &str, destination: &str, overrides: &serde_json::Map<String, serde_json::Value>) -> Result<serde_json::Value> {
|
||||
// Read source file
|
||||
let source_content = fs::read_to_string(source)?;
|
||||
let mut params = serde_json::Map::new();
|
||||
|
||||
// Parse source and apply overrides
|
||||
for line in source_content.lines() {
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
let key = key.trim().to_string();
|
||||
let mut value = value.trim()
|
||||
.trim_matches('"')
|
||||
.trim_matches('\'')
|
||||
.to_string();
|
||||
|
||||
// Apply override if exists
|
||||
if let Some(override_val) = overrides.get(&key) {
|
||||
match override_val {
|
||||
serde_json::Value::String(s) => {
|
||||
value = s.as_str().to_string();
|
||||
}
|
||||
serde_json::Value::Number(n) => {
|
||||
value = n.to_string();
|
||||
}
|
||||
serde_json::Value::Bool(b) => {
|
||||
value = b.to_string();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
params.insert(key.clone(), json!(value));
|
||||
}
|
||||
}
|
||||
|
||||
// Write destination file
|
||||
let mut content = String::new();
|
||||
for (key, value) in params {
|
||||
let param_key = key;
|
||||
match value {
|
||||
serde_json::Value::Number(n) => {
|
||||
content.push_str(&format!("{}: {}\n", param_key, n));
|
||||
}
|
||||
serde_json::Value::Bool(b) => {
|
||||
content.push_str(&format!("{}: {}\n", param_key, b));
|
||||
}
|
||||
serde_json::Value::String(s) => {
|
||||
content.push_str(&format!("{}: \"{}\"\n", param_key, s.as_str()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(destination, content)?;
|
||||
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"source": source,
|
||||
"destination": destination,
|
||||
"message": "Set file cloned successfully"
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn set_from_optimization(&self, path: &str, params: &serde_json::Map<String, serde_json::Value>, template: Option<&str>, sweep: &serde_json::Map<String, serde_json::Value>) -> Result<serde_json::Value> {
|
||||
// Generate .set file content
|
||||
let mut content = String::new();
|
||||
|
||||
// Start with template if provided
|
||||
if let Some(template_path) = template {
|
||||
if let Ok(template_content) = fs::read_to_string(template_path) {
|
||||
content.push_str(&template_content);
|
||||
content.push('\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Add optimization params
|
||||
for (key, value) in params {
|
||||
let param_key = key;
|
||||
match value {
|
||||
serde_json::Value::Number(n) => {
|
||||
content.push_str(&format!("{}={}\n", param_key, n));
|
||||
}
|
||||
serde_json::Value::String(s) => {
|
||||
content.push_str(&format!("{}={}\n", param_key, s.as_str()));
|
||||
}
|
||||
serde_json::Value::Bool(b) => {
|
||||
content.push_str(&format!("{}={}\n", param_key, b));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Add sweep parameters
|
||||
for (key, value) in sweep {
|
||||
let param_key = key;
|
||||
match value {
|
||||
serde_json::Value::Object(obj) => {
|
||||
if let Some(from) = obj.get("from") {
|
||||
content.push_str(&format!("{}_from={}\n", param_key, from.as_f64().unwrap_or(0.0)));
|
||||
}
|
||||
if let Some(to) = obj.get("to") {
|
||||
content.push_str(&format!("{}_to={}\n", param_key, to.as_f64().unwrap_or(0.0)));
|
||||
}
|
||||
if let Some(step) = obj.get("step") {
|
||||
content.push_str(&format!("{}_step={}\n", param_key, step.as_f64().unwrap_or(0.0)));
|
||||
}
|
||||
}
|
||||
serde_json::Value::Bool(optimize) => {
|
||||
if *optimize {
|
||||
content.push_str(&format!("{}=||Y\n", param_key));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fs::write(path, content)?;
|
||||
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"path": path,
|
||||
"message": "Set file generated from optimization"
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn diff_set_files(&self, path_a: &str, path_b: &str) -> Result<serde_json::Value> {
|
||||
let content_a = fs::read_to_string(path_a)?;
|
||||
let content_b = fs::read_to_string(path_b)?;
|
||||
|
||||
// Parse both files and find differences (simplified)
|
||||
let mut differences = Vec::new();
|
||||
let lines_a: Vec<&str> = content_a.lines().collect();
|
||||
let lines_b: Vec<&str> = content_b.lines().collect();
|
||||
|
||||
for (i, (line_a, line_b)) in lines_a.iter().zip(lines_b.iter()).enumerate() {
|
||||
if line_a != line_b {
|
||||
differences.push(json!({
|
||||
"line": i + 1,
|
||||
"file_a": line_a,
|
||||
"file_b": line_b
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"path_a": path_a,
|
||||
"path_b": path_b,
|
||||
"differences": differences
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn describe_sweep(&self, path: &str) -> Result<serde_json::Value> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
|
||||
// Parse sweep configuration (simplified)
|
||||
let mut sweep_params = serde_json::Map::new();
|
||||
for line in content.lines() {
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
let key = key.trim().to_string();
|
||||
let value = value.trim();
|
||||
let _clean_value = if value.starts_with('"') && value.ends_with('"') {
|
||||
value[1..value.len()-1].to_string()
|
||||
} else {
|
||||
value.to_string()
|
||||
};
|
||||
|
||||
if value.contains("||Y") {
|
||||
if let Some((from_val, to_val)) = value.split_once("..") {
|
||||
sweep_params.insert(key.clone(), json!({
|
||||
"from": from_val.trim(),
|
||||
"to": to_val.trim(),
|
||||
"step": 1.0
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"path": path,
|
||||
"sweep_params": sweep_params
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_set_files(&self) -> Result<serde_json::Value> {
|
||||
let mut set_files = Vec::new();
|
||||
|
||||
if let Some(tester_profiles_dir) = &self.config.tester_profiles_dir {
|
||||
for entry in fs::read_dir(tester_profiles_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().map(|ext| ext == "set").unwrap_or(false) {
|
||||
let file_name = path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let param_count = self.count_params_in_set(&path)?;
|
||||
let sweep_count = self.count_sweep_params_in_set(&path)?;
|
||||
|
||||
set_files.push(json!({
|
||||
"name": file_name,
|
||||
"path": path.to_string_lossy(),
|
||||
"param_count": param_count,
|
||||
"sweep_count": sweep_count,
|
||||
"sub_folder": path.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string())
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!(set_files))
|
||||
}
|
||||
|
||||
pub async fn list_jobs(&self) -> Result<serde_json::Value> {
|
||||
let mut jobs = Vec::new();
|
||||
|
||||
if let Some(opt_log_dir) = &self.config.opt_log_dir {
|
||||
for entry in fs::read_dir(opt_log_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().map(|ext| ext == "log").unwrap_or(false) {
|
||||
let file_name = path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// Simple job status detection (simplified)
|
||||
let is_alive = self.is_process_alive(&file_name);
|
||||
let elapsed = self.get_job_elapsed(&path);
|
||||
|
||||
jobs.push(json!({
|
||||
"job_id": file_name,
|
||||
"alive": is_alive,
|
||||
"elapsed": elapsed,
|
||||
"log_file": path.to_string_lossy()
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!(jobs))
|
||||
}
|
||||
|
||||
pub async fn archive_report(&self, report_dir: &str, delete_after: bool, notes: Option<String>, tags: Option<Vec<String>>) -> Result<serde_json::Value> {
|
||||
// Simplified archiving - in real implementation would parse report files
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"report_dir": report_dir,
|
||||
"delete_after": delete_after,
|
||||
"notes": notes,
|
||||
"tags": tags
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn archive_all_reports(&self, delete_after: bool, keep_last: u64, dry_run: bool) -> Result<serde_json::Value> {
|
||||
// Simplified archiving - in real implementation would scan reports directory
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"delete_after": delete_after,
|
||||
"keep_last": keep_last,
|
||||
"dry_run": dry_run
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_history(&self, ea: Option<String>, symbol: Option<String>, tag: Option<String>, verdict: Option<String>, sort_by: Option<String>, limit: u64, include_monthly: bool) -> Result<serde_json::Value> {
|
||||
// Simplified history retrieval - in real implementation would read from JSON file
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"ea": ea,
|
||||
"symbol": symbol,
|
||||
"tag": tag,
|
||||
"verdict": verdict,
|
||||
"sort_by": sort_by,
|
||||
"limit": limit,
|
||||
"include_monthly": include_monthly
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn promote_to_baseline(&self, history_id: &str, report_dir: Option<String>, notes: Option<String>) -> Result<serde_json::Value> {
|
||||
// Simplified promotion - in real implementation would update baseline.json
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"history_id": history_id,
|
||||
"report_dir": report_dir,
|
||||
"notes": notes
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn annotate_history(&self, history_id: &str, notes: Option<String>, tags: Option<Vec<String>>, verdict: Option<String>) -> Result<serde_json::Value> {
|
||||
// Simplified annotation - in real implementation would update history JSON
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"history_id": history_id,
|
||||
"notes": notes,
|
||||
"tags": tags,
|
||||
"verdict": verdict
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn prune_reports(&self, keep_last: u64) -> Result<serde_json::Value> {
|
||||
// Simplified pruning - in real implementation would delete old directories
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"keep_last": keep_last
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn list_reports(&self, limit: u64) -> Result<serde_json::Value> {
|
||||
// Simplified listing - in real implementation would scan reports directory
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"limit": limit
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn tail_log(&self, n: u64, filter: &str, log_file: Option<String>, report_dir: Option<String>, job_id: Option<String>) -> Result<serde_json::Value> {
|
||||
// Simplified tailing - in real implementation would read log file
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"n": n,
|
||||
"filter": filter,
|
||||
"log_file": log_file,
|
||||
"report_dir": report_dir,
|
||||
"job_id": job_id
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn cache_status(&self) -> Result<serde_json::Value> {
|
||||
// Simplified cache status - in real implementation would scan cache directory
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"cache_dir": self.config.tester_cache_dir.as_ref().unwrap_or(&"unknown".to_string()),
|
||||
"symbol_count": 7,
|
||||
"symbols": [
|
||||
"AUDJPYc", "AUDUSD", "EURJPYc", "USDJPY", "USDUSC", "XAUUSD.cent", "XAUUSDc"
|
||||
]
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn clean_cache(&self, symbol: Option<String>, dry_run: bool) -> Result<serde_json::Value> {
|
||||
// Simplified cache cleaning - in real implementation would delete cache files
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"symbol": symbol,
|
||||
"dry_run": dry_run
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_backtest_status(&self, report_dir: &str) -> Result<serde_json::Value> {
|
||||
// Simplified status checking - in real implementation would check for completion
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"report_dir": report_dir,
|
||||
"status": "completed"
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn get_optimization_status(&self, job_id: &str) -> Result<serde_json::Value> {
|
||||
// Simplified status checking - in real implementation would check process
|
||||
Ok(json!({
|
||||
"success": true,
|
||||
"job_id": job_id,
|
||||
"status": "running"
|
||||
}))
|
||||
}
|
||||
|
||||
// Helper methods (simplified implementations)
|
||||
fn count_params_in_set(&self, path: &Path) -> Result<u64> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
Ok(content.lines().filter(|line| line.contains(':')).count() as u64)
|
||||
}
|
||||
|
||||
fn count_sweep_params_in_set(&self, path: &Path) -> Result<u64> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
Ok(content.lines().filter(|line| line.contains("||Y")).count() as u64)
|
||||
}
|
||||
|
||||
fn is_process_alive(&self, job_id: &str) -> bool {
|
||||
// Simplified process check - in real implementation would check process list
|
||||
job_id.contains("opt_") && chrono::Utc::now().timestamp() % 3600 > 300
|
||||
}
|
||||
|
||||
fn get_job_elapsed(&self, _log_path: &Path) -> String {
|
||||
// Simplified elapsed calculation
|
||||
"5m 23s".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
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};
|
||||
|
||||
pub struct BacktestPipeline {
|
||||
config: Config,
|
||||
compiler: MqlCompiler,
|
||||
extractor: ReportExtractor,
|
||||
analyzer: DealAnalyzer,
|
||||
}
|
||||
|
||||
pub struct BacktestParams {
|
||||
pub expert: String,
|
||||
pub symbol: String,
|
||||
pub from_date: String,
|
||||
pub to_date: String,
|
||||
pub timeframe: String,
|
||||
pub deposit: u32,
|
||||
pub model: u8,
|
||||
pub leverage: u32,
|
||||
pub set_file: Option<String>,
|
||||
pub skip_compile: bool,
|
||||
pub skip_clean: bool,
|
||||
pub skip_analyze: bool,
|
||||
pub deep_analyze: bool,
|
||||
pub shutdown: bool,
|
||||
pub kill_existing: bool,
|
||||
pub timeout: u64,
|
||||
pub gui: bool,
|
||||
}
|
||||
|
||||
pub struct PipelineResult {
|
||||
pub success: bool,
|
||||
pub report_dir: PathBuf,
|
||||
pub duration_seconds: i64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl BacktestPipeline {
|
||||
pub fn new(config: Config) -> Self {
|
||||
let compiler = MqlCompiler::new(config.clone());
|
||||
let extractor = ReportExtractor::new();
|
||||
let analyzer = DealAnalyzer::new();
|
||||
|
||||
Self {
|
||||
config,
|
||||
compiler,
|
||||
extractor,
|
||||
analyzer,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(&self, params: BacktestParams) -> Result<PipelineResult> {
|
||||
let start_time = chrono::Utc::now();
|
||||
let report_id = self.generate_report_id(¶ms);
|
||||
let report_dir = self.config.reports_dir().join(&report_id);
|
||||
|
||||
fs::create_dir_all(&report_dir)?;
|
||||
|
||||
let progress_log = report_dir.join("progress.log");
|
||||
self.log_progress(&progress_log, "START").await;
|
||||
|
||||
if !params.skip_compile {
|
||||
self.log_progress(&progress_log, "COMPILE").await;
|
||||
self.compile_ea(¶ms.expert).await?;
|
||||
}
|
||||
|
||||
if !params.skip_clean {
|
||||
self.log_progress(&progress_log, "CLEAN").await;
|
||||
self.clean_cache(¶ms.expert).await?;
|
||||
}
|
||||
|
||||
self.log_progress(&progress_log, "BACKTEST").await;
|
||||
let report_path = self.run_backtest(¶ms, &report_id).await?;
|
||||
|
||||
self.log_progress(&progress_log, "EXTRACT").await;
|
||||
let extraction = self.extractor.extract(
|
||||
&report_path.to_string_lossy(),
|
||||
&report_dir.to_string_lossy()
|
||||
)?;
|
||||
|
||||
if !params.skip_analyze {
|
||||
self.log_progress(&progress_log, "ANALYZE").await;
|
||||
let analysis = self.analyzer.analyze(&extraction.deals, &extraction.metrics);
|
||||
|
||||
let analysis_path = report_dir.join("analysis.json");
|
||||
let analysis_json = serde_json::to_string_pretty(&analysis)?;
|
||||
fs::write(&analysis_path, analysis_json)?;
|
||||
}
|
||||
|
||||
self.log_progress(&progress_log, "DONE").await;
|
||||
|
||||
let duration = (chrono::Utc::now() - start_time).num_seconds();
|
||||
self.save_metadata(¶ms, &report_dir, duration).await?;
|
||||
|
||||
Ok(PipelineResult {
|
||||
success: true,
|
||||
report_dir,
|
||||
duration_seconds: duration,
|
||||
message: "Backtest completed successfully".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn compile_ea(&self, expert: &str) -> Result<()> {
|
||||
let search_paths = [
|
||||
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)),
|
||||
PathBuf::from(&self.config.get("project_dir")).join(format!("{}.mq5", expert)),
|
||||
PathBuf::from("src/experts").join(format!("{}.mq5", expert)),
|
||||
PathBuf::from("src").join(format!("{}.mq5", expert)),
|
||||
PathBuf::from(format!("{}.mq5", expert)),
|
||||
];
|
||||
|
||||
let source_path = search_paths
|
||||
.into_iter()
|
||||
.find(|p| p.exists())
|
||||
.ok_or_else(|| anyhow!("Cannot find {}.mq5", expert))?;
|
||||
|
||||
let result = self.compiler.compile(&source_path.to_string_lossy())?;
|
||||
|
||||
if !result.success {
|
||||
return Err(anyhow!(
|
||||
"Compilation failed: {}",
|
||||
result.errors.join("; ")
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn clean_cache(&self, expert: &str) -> Result<()> {
|
||||
if let Some(cache_dir) = &self.config.tester_cache_dir {
|
||||
let cache_path = Path::new(cache_dir);
|
||||
if cache_path.exists() {
|
||||
for entry in walkdir::WalkDir::new(cache_path) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "tst").unwrap_or(false) {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tester_dir) = &self.config.tester_profiles_dir {
|
||||
let cached_set = Path::new(tester_dir).join(format!("{}.set", expert));
|
||||
if cached_set.exists() {
|
||||
let _ = fs::remove_file(&cached_set);
|
||||
}
|
||||
}
|
||||
|
||||
self.reset_terminal_ini().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reset_terminal_ini(&self) -> 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");
|
||||
if !terminal_ini.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = fs::read(&terminal_ini)?;
|
||||
|
||||
let (text, encoding) = if content.starts_with(&[0xFF, 0xFE]) || content.starts_with(&[0xFE, 0xFF]) {
|
||||
let text = String::from_utf16_lossy(
|
||||
content.chunks_exact(2)
|
||||
.map(|c| u16::from_le_bytes([c[0], c[1]]))
|
||||
.collect::<Vec<_>>()
|
||||
.as_slice()
|
||||
);
|
||||
(text, "utf-16")
|
||||
} else {
|
||||
(String::from_utf8_lossy(&content).to_string(), "utf-8")
|
||||
};
|
||||
|
||||
let updated = text
|
||||
.replace("OptMode=-1", "OptMode=0")
|
||||
.replace("LastOptimization=1", "");
|
||||
|
||||
let output = if encoding == "utf-16" {
|
||||
let utf16: Vec<u16> = updated.encode_utf16().collect();
|
||||
let bytes: Vec<u8> = utf16.iter()
|
||||
.flat_map(|&c| c.to_le_bytes())
|
||||
.collect();
|
||||
bytes
|
||||
} else {
|
||||
updated.into_bytes()
|
||||
};
|
||||
|
||||
fs::write(&terminal_ini, output)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_backtest(&self, params: &BacktestParams, report_id: &str) -> Result<PathBuf> {
|
||||
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())
|
||||
.map(|p| p.to_path_buf())
|
||||
.ok_or_else(|| anyhow!("Could not determine Wine prefix"))?;
|
||||
|
||||
let reports_dir = mt5_dir.join("reports");
|
||||
fs::create_dir_all(&reports_dir)?;
|
||||
|
||||
let ini_path = mt5_dir.join("backtest_config.ini");
|
||||
let ini_content = self.build_backtest_ini(params, report_id)?;
|
||||
|
||||
let ini_utf16: Vec<u8> = std::iter::once(0xFFu8)
|
||||
.chain(std::iter::once(0xFEu8))
|
||||
.chain(ini_content.encode_utf16().flat_map(|c| c.to_le_bytes()))
|
||||
.collect();
|
||||
|
||||
fs::write(&ini_path, ini_utf16)?;
|
||||
|
||||
if params.kill_existing {
|
||||
self.kill_mt5().await?;
|
||||
}
|
||||
|
||||
let bat_content = if params.shutdown {
|
||||
format!(r#"@echo off
|
||||
cd /d "C:\Program Files\MetaTrader 5"
|
||||
start /wait terminal64.exe /config:"C:\Program Files\MetaTrader 5\backtest_config.ini"
|
||||
"#)
|
||||
} else {
|
||||
format!(r#"@echo off
|
||||
cd /d "C:\Program Files\MetaTrader 5"
|
||||
start terminal64.exe /config:"C:\Program Files\MetaTrader 5\backtest_config.ini"
|
||||
"#)
|
||||
};
|
||||
|
||||
let bat_path = wine_prefix.join("drive_c").join("_mt5mcp_run.bat");
|
||||
fs::write(&bat_path, bat_content)?;
|
||||
|
||||
let cmd = format!("cmd.exe /c 'C:\\_mt5mcp_run.bat'");
|
||||
|
||||
if params.shutdown {
|
||||
let output = Command::new("timeout")
|
||||
.arg(¶ms.timeout.to_string())
|
||||
.arg(wine_exe)
|
||||
.arg("cmd.exe")
|
||||
.arg("/c")
|
||||
.arg("C:\\_mt5mcp_run.bat")
|
||||
.env("WINEPREFIX", &wine_prefix)
|
||||
.env("WINEDEBUG", "-all")
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
tracing::warn!("MT5 exited with code: {:?}", output.status.code());
|
||||
}
|
||||
} else {
|
||||
Command::new("nohup")
|
||||
.arg(wine_exe)
|
||||
.arg("cmd.exe")
|
||||
.arg("/c")
|
||||
.arg("C:\\_mt5mcp_run.bat")
|
||||
.env("WINEPREFIX", &wine_prefix)
|
||||
.env("WINEDEBUG", "-all")
|
||||
.spawn()?;
|
||||
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(params.timeout);
|
||||
loop {
|
||||
if tokio::time::Instant::now() > deadline {
|
||||
return Err(anyhow!("Timeout waiting for backtest report"));
|
||||
}
|
||||
|
||||
for ext in &[".htm", ".htm.xml", ".html"] {
|
||||
let candidate = reports_dir.join(format!("{}{}", report_id, ext));
|
||||
if candidate.exists() {
|
||||
let _ = fs::remove_file(&bat_path);
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
}
|
||||
}
|
||||
|
||||
for ext in &[".htm", ".htm.xml", ".html"] {
|
||||
let candidate = reports_dir.join(format!("{}{}", report_id, ext));
|
||||
if candidate.exists() {
|
||||
let _ = fs::remove_file(&bat_path);
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("No report file generated"))
|
||||
}
|
||||
|
||||
fn build_backtest_ini(&self, params: &BacktestParams, report_id: &str) -> Result<String> {
|
||||
let mut ini = String::new();
|
||||
|
||||
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("[Tester]\n");
|
||||
ini.push_str(&format!("Expert={}.ex5\n", params.expert));
|
||||
ini.push_str(&format!("Symbol={}\n", params.symbol));
|
||||
ini.push_str(&format!("Period={}\n", params.timeframe));
|
||||
ini.push_str("Optimization=0\n");
|
||||
ini.push_str(&format!("Model={}\n", params.model));
|
||||
ini.push_str(&format!("FromDate={}\n", params.from_date));
|
||||
ini.push_str(&format!("ToDate={}\n", params.to_date));
|
||||
ini.push_str("ForwardMode=0\n");
|
||||
ini.push_str(&format!("Deposit={}\n", params.deposit));
|
||||
ini.push_str(&format!("Currency={}\n", self.config.backtest_currency.as_ref().unwrap_or(&"USD".to_string())));
|
||||
ini.push_str("ProfitInPips=1\n");
|
||||
ini.push_str(&format!("Leverage={}\n", params.leverage));
|
||||
ini.push_str("ExecutionMode=10\n");
|
||||
ini.push_str("OptimizationCriterion=0\n");
|
||||
ini.push_str(&format!("Visual={}\n", if params.gui { "1" } else { "0" }));
|
||||
ini.push_str(&format!("Report=reports\\{}.htm\n", report_id));
|
||||
ini.push_str("ReplaceReport=1\n");
|
||||
ini.push_str(&format!("ShutdownTerminal={}\n", if params.shutdown { "1" } else { "0" }));
|
||||
|
||||
if let Some(set_file) = ¶ms.set_file {
|
||||
ini.push_str(&format!("ExpertParameters={}\n", set_file));
|
||||
}
|
||||
|
||||
Ok(ini)
|
||||
}
|
||||
|
||||
async fn kill_mt5(&self) -> Result<()> {
|
||||
let output = Command::new("pkill")
|
||||
.args(&["-TERM", "-f", "terminal64\\.exe"])
|
||||
.output()?;
|
||||
|
||||
sleep(Duration::from_secs(5)).await;
|
||||
|
||||
let check = Command::new("pgrep")
|
||||
.args(&["-f", "terminal64\\.exe"])
|
||||
.output()?;
|
||||
|
||||
if check.status.success() {
|
||||
let _ = Command::new("pkill")
|
||||
.args(&["-KILL", "-f", "terminal64\\.exe"])
|
||||
.output();
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn log_progress(&self, log_path: &Path, stage: &str) {
|
||||
let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
|
||||
let line = format!("{} {}\n", stage, timestamp);
|
||||
let _ = fs::write(log_path, line);
|
||||
}
|
||||
|
||||
async fn save_metadata(&self, params: &BacktestParams, report_dir: &Path, duration: i64) -> Result<()> {
|
||||
let metadata = PipelineMetadata {
|
||||
expert: params.expert.clone(),
|
||||
symbol: params.symbol.clone(),
|
||||
timeframe: params.timeframe.clone(),
|
||||
from_date: params.from_date.clone(),
|
||||
to_date: params.to_date.clone(),
|
||||
deposit: params.deposit as f64,
|
||||
currency: self.config.backtest_currency.clone().unwrap_or_else(|| "USD".to_string()),
|
||||
model: params.model as i32,
|
||||
leverage: params.leverage as i32,
|
||||
set_file: params.set_file.clone(),
|
||||
report_dir: report_dir.to_string_lossy().to_string(),
|
||||
duration_seconds: duration,
|
||||
files: FilePaths {
|
||||
metrics: report_dir.join("metrics.json").to_string_lossy().to_string(),
|
||||
analysis: report_dir.join("analysis.json").to_string_lossy().to_string(),
|
||||
deals_csv: report_dir.join("deals.csv").to_string_lossy().to_string(),
|
||||
deals_json: report_dir.join("deals.json").to_string_lossy().to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&metadata)?;
|
||||
fs::write(report_dir.join("pipeline_metadata.json"), json)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn generate_report_id(&self, params: &BacktestParams) -> String {
|
||||
let timestamp = chrono::Utc::now().format("%Y%m%d_%H%M%S");
|
||||
format!(
|
||||
"{}_{}_{}_{}_{}",
|
||||
timestamp, params.expert, params.symbol, params.timeframe, params.model
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod backtest;
|
||||
pub mod stages;
|
||||
@@ -0,0 +1,107 @@
|
||||
use anyhow::Result;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Stage {
|
||||
Compile,
|
||||
Clean,
|
||||
Backtest,
|
||||
Extract,
|
||||
Analyze,
|
||||
Done,
|
||||
}
|
||||
|
||||
impl Stage {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Stage::Compile => "COMPILE",
|
||||
Stage::Clean => "CLEAN",
|
||||
Stage::Backtest => "BACKTEST",
|
||||
Stage::Extract => "EXTRACT",
|
||||
Stage::Analyze => "ANALYZE",
|
||||
Stage::Done => "DONE",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(&self) -> Option<Stage> {
|
||||
match self {
|
||||
Stage::Compile => Some(Stage::Clean),
|
||||
Stage::Clean => Some(Stage::Backtest),
|
||||
Stage::Backtest => Some(Stage::Extract),
|
||||
Stage::Extract => Some(Stage::Analyze),
|
||||
Stage::Analyze => Some(Stage::Done),
|
||||
Stage::Done => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StageExecutor;
|
||||
|
||||
impl StageExecutor {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn execute(&self, stage: Stage) -> Result<StageResult> {
|
||||
match stage {
|
||||
Stage::Compile => self.execute_compile(),
|
||||
Stage::Clean => self.execute_clean(),
|
||||
Stage::Backtest => self.execute_backtest(),
|
||||
Stage::Extract => self.execute_extract(),
|
||||
Stage::Analyze => self.execute_analyze(),
|
||||
Stage::Done => Ok(StageResult::complete()),
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_compile(&self) -> Result<StageResult> {
|
||||
Ok(StageResult::success())
|
||||
}
|
||||
|
||||
fn execute_clean(&self) -> Result<StageResult> {
|
||||
Ok(StageResult::success())
|
||||
}
|
||||
|
||||
fn execute_backtest(&self) -> Result<StageResult> {
|
||||
Ok(StageResult::success())
|
||||
}
|
||||
|
||||
fn execute_extract(&self) -> Result<StageResult> {
|
||||
Ok(StageResult::success())
|
||||
}
|
||||
|
||||
fn execute_analyze(&self) -> Result<StageResult> {
|
||||
Ok(StageResult::success())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StageResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
pub output: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl StageResult {
|
||||
pub fn success() -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
message: String::new(),
|
||||
output: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn complete() -> Self {
|
||||
Self {
|
||||
success: true,
|
||||
message: "Pipeline complete".to_string(),
|
||||
output: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
success: false,
|
||||
message: message.into(),
|
||||
output: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,495 @@
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub fn get_tools_list() -> Value {
|
||||
let tools = vec![
|
||||
tool_run_backtest(),
|
||||
tool_run_optimization(),
|
||||
tool_get_optimization_results(),
|
||||
tool_analyze_report(),
|
||||
tool_compare_baseline(),
|
||||
tool_compile_ea(),
|
||||
tool_verify_setup(),
|
||||
tool_list_symbols(),
|
||||
tool_list_experts(),
|
||||
tool_get_backtest_status(),
|
||||
tool_get_optimization_status(),
|
||||
tool_prune_reports(),
|
||||
tool_list_reports(),
|
||||
tool_tail_log(),
|
||||
tool_cache_status(),
|
||||
tool_clean_cache(),
|
||||
tool_read_set_file(),
|
||||
tool_write_set_file(),
|
||||
tool_patch_set_file(),
|
||||
tool_clone_set_file(),
|
||||
tool_set_from_optimization(),
|
||||
tool_diff_set_files(),
|
||||
tool_describe_sweep(),
|
||||
tool_list_set_files(),
|
||||
tool_list_jobs(),
|
||||
tool_archive_report(),
|
||||
tool_archive_all_reports(),
|
||||
tool_get_history(),
|
||||
tool_promote_to_baseline(),
|
||||
tool_annotate_history(),
|
||||
];
|
||||
|
||||
json!(tools)
|
||||
}
|
||||
|
||||
fn tool_run_backtest() -> Value {
|
||||
json!({
|
||||
"name": "run_backtest",
|
||||
"description": "Run a complete MT5 backtest pipeline: compile → clean cache → backtest → extract → analyze",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert"],
|
||||
"properties": {
|
||||
"expert": { "type": "string", "description": "EA name without path or extension" },
|
||||
"symbol": { "type": "string", "description": "Trading symbol" },
|
||||
"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"] },
|
||||
"deposit": { "type": "integer" },
|
||||
"model": { "type": "integer", "enum": [0, 1, 2] },
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_run_optimization() -> Value {
|
||||
json!({
|
||||
"name": "run_optimization",
|
||||
"description": "Launch MT5 genetic parameter optimization",
|
||||
"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" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_get_optimization_results() -> Value {
|
||||
json!({
|
||||
"name": "get_optimization_results",
|
||||
"description": "Parse completed MT5 optimization results",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": { "type": "string" },
|
||||
"report_file": { "type": "string" },
|
||||
"dd_threshold": { "type": "number" },
|
||||
"top_n": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_analyze_report() -> Value {
|
||||
json!({
|
||||
"name": "analyze_report",
|
||||
"description": "Read and summarize a completed backtest report",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_compare_baseline() -> Value {
|
||||
json!({
|
||||
"name": "compare_baseline",
|
||||
"description": "Compare a backtest report against a baseline",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["baseline"],
|
||||
"properties": {
|
||||
"baseline": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"net_profit": { "type": "number" },
|
||||
"max_dd_pct": { "type": "number" },
|
||||
"total_trades": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"report_dir": { "type": "string" },
|
||||
"promote_dd_limit": { "type": "number" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_compile_ea() -> Value {
|
||||
json!({
|
||||
"name": "compile_ea",
|
||||
"description": "Compile an MQL5 Expert Advisor via MetaEditor",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["expert_path"],
|
||||
"properties": {
|
||||
"expert_path": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_verify_setup() -> Value {
|
||||
json!({
|
||||
"name": "verify_setup",
|
||||
"description": "Verify MT5-Quant environment",
|
||||
"inputSchema": { "type": "object", "properties": {} }
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_list_symbols() -> Value {
|
||||
json!({
|
||||
"name": "list_symbols",
|
||||
"description": "List symbols with local tick history",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"server": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_list_experts() -> Value {
|
||||
json!({
|
||||
"name": "list_experts",
|
||||
"description": "List all compiled Expert Advisors",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_get_backtest_status() -> Value {
|
||||
json!({
|
||||
"name": "get_backtest_status",
|
||||
"description": "Check progress of a running backtest pipeline",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_get_optimization_status() -> Value {
|
||||
json!({
|
||||
"name": "get_optimization_status",
|
||||
"description": "Check if an optimization job is running",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["job_id"],
|
||||
"properties": {
|
||||
"job_id": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_prune_reports() -> Value {
|
||||
json!({
|
||||
"name": "prune_reports",
|
||||
"description": "Delete old backtest report directories",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"keep_last": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_list_reports() -> Value {
|
||||
json!({
|
||||
"name": "list_reports",
|
||||
"description": "List all backtest report directories",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": { "type": "integer" },
|
||||
"include_opt": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_tail_log() -> Value {
|
||||
json!({
|
||||
"name": "tail_log",
|
||||
"description": "Read the last N lines of a log file",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"n": { "type": "integer" },
|
||||
"filter": { "type": "string", "enum": ["all", "errors", "warnings"] },
|
||||
"log_file": { "type": "string" },
|
||||
"report_dir": { "type": "string" },
|
||||
"job_id": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_cache_status() -> Value {
|
||||
json!({
|
||||
"name": "cache_status",
|
||||
"description": "Show MT5 tester cache size breakdown",
|
||||
"inputSchema": { "type": "object", "properties": {} }
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_clean_cache() -> Value {
|
||||
json!({
|
||||
"name": "clean_cache",
|
||||
"description": "Delete MT5 tester cache files",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"symbol": { "type": "string" },
|
||||
"dry_run": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_read_set_file() -> Value {
|
||||
json!({
|
||||
"name": "read_set_file",
|
||||
"description": "Parse an MT5 .set parameter file",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"path": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_write_set_file() -> Value {
|
||||
json!({
|
||||
"name": "write_set_file",
|
||||
"description": "Write an MT5 .set parameter file",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["path", "params"],
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"params": { "type": "object" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_patch_set_file() -> Value {
|
||||
json!({
|
||||
"name": "patch_set_file",
|
||||
"description": "Modify specific parameters in a .set file",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["path", "patches"],
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"patches": { "type": "object" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_clone_set_file() -> Value {
|
||||
json!({
|
||||
"name": "clone_set_file",
|
||||
"description": "Copy a .set file to a new path with optional overrides",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["source", "destination"],
|
||||
"properties": {
|
||||
"source": { "type": "string" },
|
||||
"destination": { "type": "string" },
|
||||
"overrides": { "type": "object" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_set_from_optimization() -> Value {
|
||||
json!({
|
||||
"name": "set_from_optimization",
|
||||
"description": "Generate .set file from optimization result params",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["path", "params"],
|
||||
"properties": {
|
||||
"path": { "type": "string" },
|
||||
"params": { "type": "object" },
|
||||
"template": { "type": "string" },
|
||||
"sweep": { "type": "object" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_diff_set_files() -> Value {
|
||||
json!({
|
||||
"name": "diff_set_files",
|
||||
"description": "Compare two .set files",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["path_a", "path_b"],
|
||||
"properties": {
|
||||
"path_a": { "type": "string" },
|
||||
"path_b": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_describe_sweep() -> Value {
|
||||
json!({
|
||||
"name": "describe_sweep",
|
||||
"description": "Show .set file sweep configuration",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["path"],
|
||||
"properties": {
|
||||
"path": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_list_set_files() -> Value {
|
||||
json!({
|
||||
"name": "list_set_files",
|
||||
"description": "List all .set files in tester profiles directory",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ea": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_list_jobs() -> Value {
|
||||
json!({
|
||||
"name": "list_jobs",
|
||||
"description": "List all optimization jobs",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"include_done": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_archive_report() -> Value {
|
||||
json!({
|
||||
"name": "archive_report",
|
||||
"description": "Convert report to JSON and append to history",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" },
|
||||
"delete_after": { "type": "boolean" },
|
||||
"notes": { "type": "string" },
|
||||
"tags": { "type": "array", "items": { "type": "string" } },
|
||||
"verdict": { "type": "string", "enum": ["winner", "loser", "marginal", "reference"] }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_archive_all_reports() -> Value {
|
||||
json!({
|
||||
"name": "archive_all_reports",
|
||||
"description": "Bulk-archive all backtest reports",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"delete_after": { "type": "boolean" },
|
||||
"keep_last": { "type": "integer" },
|
||||
"dry_run": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_get_history() -> Value {
|
||||
json!({
|
||||
"name": "get_history",
|
||||
"description": "Query backtest history with filters",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ea": { "type": "string" },
|
||||
"symbol": { "type": "string" },
|
||||
"tag": { "type": "string" },
|
||||
"verdict": { "type": "string" },
|
||||
"sort_by": { "type": "string" },
|
||||
"limit": { "type": "integer" },
|
||||
"include_monthly": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_promote_to_baseline() -> Value {
|
||||
json!({
|
||||
"name": "promote_to_baseline",
|
||||
"description": "Promote a backtest result to baseline",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"history_id": { "type": "string" },
|
||||
"report_dir": { "type": "string" },
|
||||
"notes": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_annotate_history() -> Value {
|
||||
json!({
|
||||
"name": "annotate_history",
|
||||
"description": "Add notes/verdict/tags to a history entry",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["history_id"],
|
||||
"properties": {
|
||||
"history_id": { "type": "string" },
|
||||
"notes": { "type": "string" },
|
||||
"tags": { "type": "array", "items": { "type": "string" } },
|
||||
"add_tags": { "type": "array", "items": { "type": "string" } },
|
||||
"verdict": { "type": "string", "enum": ["winner", "loser", "marginal", "reference"] }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use crate::compile::MqlCompiler;
|
||||
use crate::models::Config;
|
||||
use crate::pipeline::backtest::{BacktestParams, BacktestPipeline};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ToolHandler {
|
||||
config: Config,
|
||||
}
|
||||
|
||||
impl ToolHandler {
|
||||
pub fn new(config: Config) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub async fn handle(&self, name: &str, args: &Value) -> Result<Value> {
|
||||
match name {
|
||||
"verify_setup" => self.handle_verify_setup().await,
|
||||
"list_symbols" => self.handle_list_symbols().await,
|
||||
"list_experts" => self.handle_list_experts(args).await,
|
||||
"run_backtest" => self.handle_run_backtest(args).await,
|
||||
"compile_ea" => self.handle_compile_ea(args).await,
|
||||
"get_backtest_status" => self.handle_get_backtest_status(args).await,
|
||||
"cache_status" => self.handle_cache_status().await,
|
||||
"clean_cache" => self.handle_clean_cache(args).await,
|
||||
"list_reports" => self.handle_list_reports(args).await,
|
||||
"prune_reports" => self.handle_prune_reports(args).await,
|
||||
"list_set_files" => self.handle_list_set_files().await,
|
||||
"describe_sweep" => self.handle_describe_sweep(args).await,
|
||||
_ => Ok(json!({
|
||||
"content": [{ "type": "text", "text": format!("Tool '{}' not implemented", name) }],
|
||||
"isError": true
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_verify_setup(&self) -> Result<Value> {
|
||||
let mut checks = HashMap::new();
|
||||
let mut all_ok = true;
|
||||
|
||||
let config_path = Config::get_config_path();
|
||||
checks.insert("config_file", json!({
|
||||
"ok": config_path.exists(),
|
||||
"detail": config_path.to_string_lossy()
|
||||
}));
|
||||
if !config_path.exists() {
|
||||
all_ok = false;
|
||||
}
|
||||
|
||||
if let Some(wine) = &self.config.wine_executable {
|
||||
let wine_ok = Path::new(wine).exists();
|
||||
checks.insert("wine_executable", json!({ "ok": wine_ok, "detail": wine }));
|
||||
if !wine_ok { all_ok = false; }
|
||||
} else {
|
||||
checks.insert("wine_executable", json!({ "ok": false, "detail": "not set" }));
|
||||
all_ok = false;
|
||||
}
|
||||
|
||||
if let Some(term) = &self.config.terminal_dir {
|
||||
let term_ok = Path::new(term).is_dir();
|
||||
checks.insert("terminal_dir", json!({ "ok": term_ok, "detail": term }));
|
||||
if !term_ok { all_ok = false; }
|
||||
} else {
|
||||
checks.insert("terminal_dir", json!({ "ok": false, "detail": "not set" }));
|
||||
all_ok = false;
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"all_ok": all_ok,
|
||||
"checks": checks,
|
||||
"hint": if all_ok { "Environment looks good" } else { "Run: bash scripts/setup.sh" }
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_list_symbols(&self) -> Result<Value> {
|
||||
let symbols = vec!["XAUUSD", "EURUSD", "GBPUSD", "USDJPY", "AUDUSD"];
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"active_server": "Demo",
|
||||
"symbols": symbols
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_list_experts(&self, 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) = &self.config.experts_dir {
|
||||
if let Ok(entries) = fs::read_dir(experts_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "ex5").unwrap_or(false) {
|
||||
let name = path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
if let Some(f) = filter {
|
||||
if !name.to_lowercase().contains(&f.to_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
experts.push(json!({
|
||||
"name": name,
|
||||
"path": path.to_string_lossy()
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({ "experts": experts }).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_run_backtest(&self, args: &Value) -> Result<Value> {
|
||||
let expert = args.get("expert")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("expert is required"))?;
|
||||
|
||||
let symbol = args.get("symbol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("XAUUSD");
|
||||
|
||||
let from_date = args.get("from_date")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("from_date is required"))?;
|
||||
|
||||
let to_date = args.get("to_date")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("to_date is required"))?;
|
||||
|
||||
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: args.get("skip_analyze").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
deep_analyze: args.get("deep").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
shutdown: args.get("shutdown").and_then(|v| v.as_bool()).unwrap_or(false),
|
||||
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),
|
||||
};
|
||||
|
||||
let pipeline = BacktestPipeline::new(self.config.clone());
|
||||
let result = pipeline.run(params).await?;
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": result.success,
|
||||
"report_dir": result.report_dir.to_string_lossy(),
|
||||
"duration_seconds": result.duration_seconds,
|
||||
"message": result.message
|
||||
}).to_string() }],
|
||||
"isError": !result.success
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_compile_ea(&self, args: &Value) -> Result<Value> {
|
||||
let expert_path = args.get("expert_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("expert_path is required"))?;
|
||||
|
||||
let compiler = MqlCompiler::new(self.config.clone());
|
||||
let result = compiler.compile(expert_path)?;
|
||||
|
||||
if result.success {
|
||||
let path_str = result.ex5_path.as_ref().map(|p| p.to_string_lossy().to_string());
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"ex5_path": path_str,
|
||||
"binary_size": result.binary_size,
|
||||
"warnings": result.warnings.len()
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
} else {
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": false,
|
||||
"errors": result.errors
|
||||
}).to_string() }],
|
||||
"isError": true
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_get_backtest_status(&self, 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 status = 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"
|
||||
}
|
||||
} else {
|
||||
"unknown"
|
||||
}
|
||||
} else {
|
||||
"not_started"
|
||||
};
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"report_dir": report_dir,
|
||||
"status": status
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_cache_status(&self) -> Result<Value> {
|
||||
let cache_dir = self.config.tester_cache_dir.as_ref()
|
||||
.map(|s| Path::new(s))
|
||||
.filter(|p| p.exists());
|
||||
|
||||
let mut total_size: u64 = 0;
|
||||
let mut symbols = Vec::new();
|
||||
|
||||
if let Some(dir) = cache_dir {
|
||||
for entry in walkdir::WalkDir::new(dir).max_depth(2) {
|
||||
if let Ok(entry) = entry {
|
||||
if entry.file_type().is_dir() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
symbols.push(name.to_string());
|
||||
}
|
||||
} else {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
total_size += meta.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"cache_dir": cache_dir.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
|
||||
"total_bytes": total_size,
|
||||
"symbols": symbols
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_clean_cache(&self, args: &Value) -> Result<Value> {
|
||||
let _symbol = args.get("symbol").and_then(|v| v.as_str());
|
||||
let dry_run = args.get("dry_run").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
|
||||
let cache_dir = self.config.tester_cache_dir.as_ref()
|
||||
.map(|s| Path::new(s))
|
||||
.filter(|p| p.exists());
|
||||
|
||||
let mut bytes_freed: u64 = 0;
|
||||
|
||||
if let Some(dir) = cache_dir {
|
||||
for entry in walkdir::WalkDir::new(dir) {
|
||||
if let Ok(entry) = entry {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "tst").unwrap_or(false) {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
bytes_freed += meta.len();
|
||||
if !dry_run {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"bytes_freed": bytes_freed,
|
||||
"dry_run": dry_run
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_list_reports(&self, args: &Value) -> Result<Value> {
|
||||
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(30) as usize;
|
||||
|
||||
let reports_dir = self.config.reports_dir();
|
||||
let mut reports = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&reports_dir) {
|
||||
let mut entries: Vec<_> = entries.flatten().collect();
|
||||
entries.sort_by(|a, b| {
|
||||
b.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH)
|
||||
.cmp(&a.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH))
|
||||
});
|
||||
|
||||
for entry in entries.into_iter().take(limit) {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let name = path.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let metrics_file = path.join("metrics.json");
|
||||
let mut profit = 0.0;
|
||||
let mut dd = 0.0;
|
||||
let mut trades = 0;
|
||||
|
||||
if let Ok(content) = fs::read_to_string(&metrics_file) {
|
||||
if let Ok(metrics) = serde_json::from_str::<Value>(&content) {
|
||||
profit = metrics.get("net_profit").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
dd = metrics.get("max_dd_pct").and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
trades = metrics.get("total_trades").and_then(|v| v.as_i64()).unwrap_or(0) as i32;
|
||||
}
|
||||
}
|
||||
|
||||
reports.push(json!({
|
||||
"name": name,
|
||||
"profit": profit,
|
||||
"max_dd_pct": dd,
|
||||
"trades": trades
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({ "reports": reports }).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_prune_reports(&self, args: &Value) -> Result<Value> {
|
||||
let keep_last = args.get("keep_last").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
|
||||
|
||||
let reports_dir = self.config.reports_dir();
|
||||
let mut pruned = 0;
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&reports_dir) {
|
||||
let mut entries: Vec<_> = entries.flatten().collect();
|
||||
entries.sort_by(|a, b| {
|
||||
b.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH)
|
||||
.cmp(&a.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH))
|
||||
});
|
||||
|
||||
for entry in entries.into_iter().skip(keep_last) {
|
||||
let path = entry.path();
|
||||
if path.is_dir() && !path.to_string_lossy().ends_with("_opt") {
|
||||
let _ = fs::remove_dir_all(&path);
|
||||
pruned += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"pruned": pruned,
|
||||
"kept": keep_last
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_list_set_files(&self) -> Result<Value> {
|
||||
let mut set_files = Vec::new();
|
||||
|
||||
if let Some(tester_dir) = &self.config.tester_profiles_dir {
|
||||
if let Ok(entries) = fs::read_dir(tester_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "set").unwrap_or(false) {
|
||||
let name = path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap_or_default();
|
||||
let param_count = content.lines().filter(|l| l.contains(':')).count();
|
||||
let sweep_count = content.lines().filter(|l| l.contains("||Y")).count();
|
||||
|
||||
set_files.push(json!({
|
||||
"name": name,
|
||||
"path": path.to_string_lossy(),
|
||||
"param_count": param_count,
|
||||
"sweep_count": sweep_count
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({ "set_files": set_files }).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_describe_sweep(&self, args: &Value) -> Result<Value> {
|
||||
let path = args.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("path is required"))?;
|
||||
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut sweep_params = serde_json::Map::new();
|
||||
|
||||
for line in content.lines() {
|
||||
if let Some((key, value)) = line.split_once(':') {
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
if value.contains("||Y") {
|
||||
if let Some((from_val, to_val)) = value.split_once("..") {
|
||||
sweep_params.insert(key.to_string(), json!({
|
||||
"from": from_val.trim(),
|
||||
"to": to_val.trim().replace("||Y", ""),
|
||||
"step": 1.0
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": json!({
|
||||
"success": true,
|
||||
"path": path,
|
||||
"sweep_params": sweep_params
|
||||
}).to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod definitions;
|
||||
pub mod handlers;
|
||||
|
||||
pub use definitions::get_tools_list;
|
||||
pub use handlers::ToolHandler;
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Testing final Rust MCP Server with continuous session..."
|
||||
|
||||
# Create a temporary file for test
|
||||
TEMP_FILE=$(mktemp)
|
||||
cat > "$TEMP_FILE" << 'EOF'
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
|
||||
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"verify_setup","arguments":{}}}
|
||||
EOF
|
||||
|
||||
# Send all requests in one session
|
||||
cat "$TEMP_FILE" | /opt/homebrew/bin/mt5-quant
|
||||
|
||||
# Clean up
|
||||
rm "$TEMP_FILE"
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test script for MCP server"""
|
||||
import subprocess
|
||||
import json
|
||||
import sys
|
||||
|
||||
def test_mcp_server(command):
|
||||
"""Test if an MCP server responds correctly"""
|
||||
print(f"Testing: {' '.join(command)}")
|
||||
print("-" * 50)
|
||||
|
||||
proc = subprocess.Popen(
|
||||
command,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1 # Line buffered
|
||||
)
|
||||
|
||||
# Send initialize request
|
||||
init_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "test", "version": "1.0"}
|
||||
}
|
||||
}
|
||||
|
||||
print(f"Sending initialize request...")
|
||||
proc.stdin.write(json.dumps(init_request) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
# Read response with timeout
|
||||
import select
|
||||
import time
|
||||
|
||||
start = time.time()
|
||||
response_lines = []
|
||||
|
||||
while time.time() - start < 5: # 5 second timeout
|
||||
ready, _, _ = select.select([proc.stdout], [], [], 0.5)
|
||||
if ready:
|
||||
line = proc.stdout.readline()
|
||||
if line:
|
||||
response_lines.append(line.strip())
|
||||
print(f"Received: {line.strip()}")
|
||||
break
|
||||
|
||||
if not response_lines:
|
||||
print("ERROR: No response received within 5 seconds")
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
return False
|
||||
|
||||
# Parse response
|
||||
try:
|
||||
response = json.loads(response_lines[0])
|
||||
if response.get("id") == 1 and "result" in response:
|
||||
print("SUCCESS: MCP server responded correctly")
|
||||
|
||||
# Try to get tools list
|
||||
tools_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "tools/list",
|
||||
"params": {}
|
||||
}
|
||||
proc.stdin.write(json.dumps(tools_request) + "\n")
|
||||
proc.stdin.flush()
|
||||
|
||||
start = time.time()
|
||||
while time.time() - start < 5:
|
||||
ready, _, _ = select.select([proc.stdout], [], [], 0.5)
|
||||
if ready:
|
||||
line = proc.stdout.readline()
|
||||
if line:
|
||||
print(f"Tools response: {line.strip()[:200]}...")
|
||||
break
|
||||
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
return True
|
||||
else:
|
||||
print(f"ERROR: Unexpected response: {response}")
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
return False
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"ERROR: Invalid JSON response: {e}")
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test Python source
|
||||
print("\n=== Testing Python Source ===")
|
||||
python_ok = test_mcp_server([sys.executable, "server/main.py"])
|
||||
|
||||
# Test executable
|
||||
print("\n=== Testing PyInstaller Executable ===")
|
||||
exe_ok = test_mcp_server(["./dist/mt5-quant/mt5-quant"])
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(f"Python source: {'OK' if python_ok else 'FAILED'}")
|
||||
print(f"PyInstaller exe: {'OK' if exe_ok else 'FAILED'}")
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Testing Rust MCP Server..."
|
||||
|
||||
# Start the server in background
|
||||
./target/debug/mt5-quant &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Give it time to start
|
||||
sleep 1
|
||||
|
||||
# Test initialization
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}' | ./target/debug/mt5-quant
|
||||
|
||||
# Wait a bit
|
||||
sleep 1
|
||||
|
||||
# Test tools list
|
||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | ./target/debug/mt5-quant
|
||||
|
||||
# Wait a bit
|
||||
sleep 1
|
||||
|
||||
# Test tool call
|
||||
echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"verify_setup","arguments":{}}}' | ./target/debug/mt5-quant
|
||||
|
||||
# Clean up
|
||||
kill $SERVER_PID 2>/dev/null
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "Testing Rust MCP Server with continuous session..."
|
||||
|
||||
# Create a temporary file for the test
|
||||
TEMP_FILE=$(mktemp)
|
||||
cat > "$TEMP_FILE" << 'EOF'
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
|
||||
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"verify_setup","arguments":{}}}
|
||||
EOF
|
||||
|
||||
# Send all requests in one session
|
||||
cat "$TEMP_FILE" | ./target/debug/mt5-quant
|
||||
|
||||
# Clean up
|
||||
rm "$TEMP_FILE"
|
||||
Reference in New Issue
Block a user