6 Commits

Author SHA1 Message Date
Devid HW a1434914e9 chore: update release workflow and server.json for v1.30.0
- Update server.json: version 1.30.0, new download URL, updated description
- Add MCP package build job to release workflow
- Add SHA256 calculation for server.json
- Update release workflow to upload mcp-* package
2026-04-20 02:51:36 +07:00
Devid HW 6ce8808948 v1.30.0: Add 10 Wine/MT5 debugging tools
New debugging/diagnostics tools for crash investigation:
- diagnose_wine: Check Wine installation and prefix health
- get_mt5_logs: Get terminal/tester/metaeditor logs
- search_mt5_errors: Search logs for error patterns
- check_mt5_process: Check MT5 process status
- kill_mt5_process: Kill stuck MT5 processes
- check_system_resources: Check disk/memory/CPU
- validate_mt5_config: Validate MT5 configuration
- get_wine_prefix_info: Wine prefix details
- get_backtest_crash_info: Investigate backtest failures

Total tools: 85
Documentation updated in README.md and MCP_TOOLS.md
2026-04-20 02:25:07 +07:00
Devid HW 896aa6111e feat: auto-detect and register MCP on multiple platforms
- Add multi-platform MCP detection (Claude, Windsurf, Cursor, VS Code)
- Auto-unregister previous installations before reinstall
- Create platform-specific docs (CURSOR.md, VSCODE.md, ANTIGRAVITY.md)
- Update Windsurf config to use correct JSON path (~/.codeium/windsurf/mcp_config.json)
- Fix VS Code config format (uses 'servers' not 'mcpServers')
- Remove MT5_MCP_HOME requirement from MCP configs (binary auto-detects)
- Slim down README installation section with platform table
- Update QUICKSTART.md with all platform instructions
2026-04-20 00:40:41 +07:00
Devid aab34a1fde Add GitHub Actions workflow for Rust project 2026-04-19 10:16:03 +07:00
Devid HW 96c7e0a9c2 chore: update server.json for v1.29.1 release 2026-04-19 10:10:55 +07:00
Devid HW 0c82501e18 chore: bump version to 1.29.1 2026-04-19 10:10:02 +07:00
22 changed files with 4318 additions and 109 deletions
+22
View File
@@ -0,0 +1,22 @@
name: Rust
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
Generated
+1 -1
View File
@@ -481,7 +481,7 @@ dependencies = [
[[package]]
name = "mt5-quant"
version = "1.29.0"
version = "1.30.0"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "mt5-quant"
version = "1.29.0"
version = "1.30.0"
edition = "2021"
description = "MT5-Quant MCP Server - Exposes MT5 backtest and optimization tools via MCP"
authors = ["masdevid <masdevid@example.com>"]
+59 -49
View File
@@ -1,6 +1,6 @@
# MT5-Quant
**MCP server for MT5 strategy development on macOS/Linux.** 57 tools to compile, backtest, analyze, optimize, and manage MQL5 Expert Advisors — no Windows required.
**MCP server for MT5 strategy development on macOS/Linux.** 85 tools to compile, backtest, analyze, optimize, debug crashes, and manage MQL5 Expert Advisors — no Windows required.
```
You: "Backtest MyEA Jan-Mar, what caused the February drawdown?"
@@ -20,6 +20,7 @@ Claude: [compile → clean → backtest → analyze 1,847 deals]
| MQL5 compilation | ✅ | ❌ | ❌ |
| macOS/Linux native | ✅ | Windows only | Cloud |
| Optimization | ✅ Background | ❌ | ✅ Paid |
| Crash debugging | ✅ Wine/MT5 diagnostics | ❌ | ❌ |
## Quick Install
@@ -33,50 +34,15 @@ bash scripts/setup.sh
### 2. Register MCP Server
#### Claude Code
| Platform | Command / Config | Docs |
|----------|------------------|------|
| **Claude Code** | `claude mcp add mt5-quant -- $(pwd)/mt5-quant` | [Setup →](docs/QUICKSTART.md) |
| **Windsurf** | Edit `~/.codeium/windsurf/mcp_config.json` | [WINDSURF.md →](docs/WINDSURF.md) |
| **Cursor** | Edit `~/.cursor/mcp.json` or use Settings → MCP | [CURSOR.md →](docs/CURSOR.md) |
| **VS Code** | Edit `.vscode/mcp.json` or run `MCP: Add Server` | [VSCODE.md →](docs/VSCODE.md) |
| **Antigravity** | Agent Panel → ... → MCP Servers → Edit configuration | [ANTIGRAVITY.md →](docs/ANTIGRAVITY.md) |
```bash
# Navigate to your project directory first
cd /path/to/your/mt5-quant
# Register MCP server (requires absolute path)
claude mcp add MT5-Quant -- $(pwd)/mt5-quant
# Verify installation
claude mcp list
```
#### Windsurf
Add to `~/.windsurf/config.yaml`:
```yaml
mcpServers:
mt5-quant:
command: /absolute/path/to/mt5-quant
env:
MT5_MCP_HOME: /absolute/path/to/mt5-quant
```
Or use the config command:
```bash
# Get absolute path
which mt5-quant
# Add to Windsurf config
cat >> ~/.windsurf/config.yaml << EOF
mcpServers:
mt5-quant:
command: $(which mt5-quant)
env:
MT5_MCP_HOME: $(dirname $(which mt5-quant))
EOF
```
**[Full Setup →](docs/QUICKSTART.md)**
> **Note:** MCP servers require **absolute paths**. Use `$(pwd)` or full path like `/Users/name/mt5-quant/mt5-quant`, not relative paths like `./mt5-quant`.
> **Note:** Use absolute paths like `/Users/name/mt5-quant/mt5-quant` or `$(pwd)/mt5-quant`, not relative paths like `./mt5-quant`.
## Quick Start
@@ -91,14 +57,17 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
| Doc | Purpose |
|-----|---------|
| [QUICKSTART.md](docs/QUICKSTART.md) | Complete setup for macOS/Linux |
| [WINDSURF.md](docs/WINDSURF.md) | Windsurf IDE setup |
| [CURSOR.md](docs/CURSOR.md) | Cursor IDE setup |
| [VSCODE.md](docs/VSCODE.md) | VS Code setup |
| [ANTIGRAVITY.md](docs/ANTIGRAVITY.md) | Antigravity IDE setup |
| [CONFIG.md](docs/CONFIG.md) | Configuration reference |
| [WINDSURF.md](docs/WINDSURF.md) | Windsurf IDE integration |
| [TOOLS.md](docs/MCP_TOOLS.md) | All 57 tools documented |
| [TOOLS.md](docs/MCP_TOOLS.md) | All 75 tools documented |
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | Design and internals |
| [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) | Common issues |
| [REMOTE_AGENTS.md](docs/REMOTE_AGENTS.md) | Linux optimization agents |
## MCP Tools (57)
## MCP Tools (75)
### Core workflow
@@ -130,6 +99,21 @@ The AI runs the full pipeline: compile → clean cache → backtest → extract
Use these for targeted analysis, or `analyze_report` to run all at once.
### Deal-Level Analytics (New)
| Tool | Description |
|------|-------------|
| `list_deals` | List individual deals with filters (type, profit range, volume, dates) |
| `search_deals_by_comment` | Full-text search in deal comments (e.g., "Layer #3") |
| `search_deals_by_magic` | Filter deals by EA magic number |
| `analyze_profit_distribution` | Profit histogram: small/medium/large wins and losses |
| `analyze_time_performance` | Performance by hour of day and day of week |
| `analyze_hold_time_distribution` | Hold time buckets + correlation with profit |
| `analyze_layer_performance` | Grid/martingale layer analysis from comments |
| `analyze_volume_vs_profit` | Volume correlation + performance by lot size |
| `analyze_costs` | Commission and swap impact on profitability |
| `analyze_efficiency` | Profit per hour/day, annualized return, trade frequency |
### Monitoring
| Tool | Description |
@@ -146,6 +130,14 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
| `list_reports` | Compact table of all runs with key metrics — no full analysis needed |
| `get_latest_report` | Get most recent report with optional equity chart |
| `search_reports` | Find reports by EA, symbol, date range, or profit criteria |
| `get_report_by_id` | Get specific report by ID with equity chart |
| `get_reports_summary` | Aggregate stats: counts, averages, pass rates |
| `get_best_reports` | Top N reports sorted by any metric (profit factor, drawdown, etc.) |
| `search_reports_by_tags` | Find reports by tags |
| `search_reports_by_date_range` | Query by backtest date range |
| `search_reports_by_notes` | Full-text search in report notes |
| `get_reports_by_set_file` | Find all reports using a specific .set file |
| `get_comparable_reports` | Find comparable reports (same EA/symbol/timeframe) |
| `tail_log` | Read last N lines of any log; `filter=errors` to see only failures |
| `prune_reports` | Delete old report directories, keep last N (skips `_opt` dirs) |
@@ -175,6 +167,20 @@ Use these for targeted analysis, or `analyze_report` to run all at once.
| `check_mt5_status` | Check if MT5 terminal is installed and ready |
| `validate_ea_syntax` | Pre-compile syntax check without running full compilation |
### Debugging & Diagnostics (New)
| Tool | Description |
|------|-------------|
| `diagnose_wine` | Check Wine installation, version, and prefix health |
| `get_mt5_logs` | Get MT5 terminal, tester, or MetaEditor logs with filtering |
| `search_mt5_errors` | Search logs for error patterns (crash, exception, access violation) |
| `check_mt5_process` | Check if MT5 processes are running, get PID, CPU, memory usage |
| `kill_mt5_process` | Kill stuck MT5 processes (force=true for wineserver) |
| `check_system_resources` | Check disk space, memory, CPU availability |
| `validate_mt5_config` | Validate terminal.ini and tester configuration files |
| `get_wine_prefix_info` | Get Wine prefix details: Windows version, installed programs, registry |
| `get_backtest_crash_info` | Investigate backtest failures: incomplete markers, missing deals.csv, errors |
### Project Management
| Tool | Description |
@@ -224,6 +230,12 @@ Full schema: [docs/MCP_TOOLS.md](docs/MCP_TOOLS.md)
Run `verify_setup` from Claude first — it checks all paths and returns actionable hints.
For crashes or unexplained failures during backtest/compile/optimization:
- `diagnose_wine` — Check Wine installation and prefix health
- `search_mt5_errors` — Find crash causes in logs
- `check_mt5_process` + `kill_mt5_process` — Detect and kill stuck processes
- `get_backtest_crash_info` — Investigate failed backtest reports
**[Full Troubleshooting Guide →](docs/TROUBLESHOOTING.md)**
---
@@ -233,5 +245,3 @@ Run `verify_setup` from Claude first — it checks all paths and returns actiona
MIT
---
*Built from battle-tested production infrastructure. Every edge case in the pipeline was hit in production.*
+145
View File
@@ -0,0 +1,145 @@
# Antigravity MCP Integration Setup
## Quick Setup
### Option 1: Download Prebuilt Binary (Recommended)
```bash
# macOS (Apple Silicon)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 2: Build from Source
```bash
cargo build --release
```
## Configure Antigravity
### Step 1: Open MCP Manager
1. Launch Antigravity
2. Look at the **right-side Agent Panel**
3. Click the **"..."** (More Options) menu at the top
4. Select **MCP Servers**
### Step 2: Access Configuration
1. Click **Manage MCP Servers**
2. Click **View raw config** or **Edit configuration**
3. This opens `mcp_config.json`
### Step 3: Add mt5-quant Configuration
Add to `mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/absolute/path/to/mt5-quant"
}
}
}
```
### Step 4: Reload and Verify
1. Save the file
2. **Restart Antigravity** (or reload the window)
3. Open the Agent chat and type: `What tools do you have access to?`
4. The agent should list MT5 tools like `verify_setup`, `run_backtest`, etc.
## Full Example Configuration
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/name/mt5-quant/target/release/mt5-quant"
},
"github": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${env:GITHUB_TOKEN}"
}
}
}
}
```
## Environment Variables
Antigravity supports `${VAR_NAME}` syntax for environment variable substitution:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/name/mt5-quant/target/release/mt5-quant",
"env": {
"CUSTOM_VAR": "${env:MY_VAR}"
}
}
}
}
```
This keeps secrets out of the config file.
## Verify Setup
In Antigravity chat, type:
```
Run verify_setup
```
Expected output:
```
Wine: /Applications/MetaTrader 5.app/.../wine64
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
Display: gui
Arch: arch -x86_64
```
## Troubleshooting
### "Connection Refused" or "Tool not found"
1. Double-check the server path in `mcp_config.json`
2. Ensure the binary has execute permissions: `chmod +x /path/to/mt5-quant`
3. Try completely restarting Antigravity
4. Check the server is listed in MCP manager
### "Stdio Error" or JSON Parsing Error
1. Verify the JSON syntax in `mcp_config.json`
2. Use a JSON validator if needed
3. Ensure no trailing commas
### Agent Hallucinating Tool Parameters
If the agent makes up incorrect parameters:
1. Check the tool is actually available: "List your available tools"
2. Restart the agent session
3. Be explicit in your requests
## Configuration Location
| Platform | Path |
|----------|------|
| All | Via UI: Agent Panel → ... → MCP Servers → Manage → Edit configuration |
## Resources
- [Antigravity Documentation](https://docs.antigravity.dev/)
- [MCP Server Reference](https://github.com/modelcontextprotocol/servers)
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
+129
View File
@@ -0,0 +1,129 @@
# Cursor MCP Integration Setup
## Quick Setup
### Option 1: Download Prebuilt Binary (Recommended)
```bash
# macOS (Apple Silicon)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 2: Build from Source
```bash
cargo build --release
```
## Configure Cursor
### Method 1: Settings UI (Recommended)
1. Open Cursor Settings (`Cmd/Ctrl + ,`)
2. Navigate to **Features****MCP**
3. Click **Add Custom MCP**
4. Enter:
- **Name**: `mt5-quant`
- **Command**: Full path to binary (e.g., `/Users/name/mt5-quant/target/release/mt5-quant`)
- **Type**: `stdio`
### Method 2: Edit mcp.json Directly
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
```json
{
"mcpServers": {
"mt5-quant": {
"type": "stdio",
"command": "/absolute/path/to/mt5-quant"
}
}
}
```
Create the file if it doesn't exist:
```bash
mkdir -p ~/.cursor
cat > ~/.cursor/mcp.json << 'EOF'
{
"mcpServers": {
"mt5-quant": {
"type": "stdio",
"command": "/path/to/mt5-quant"
}
}
}
EOF
```
## Verify Setup
In Cursor chat, type:
```
Run verify_setup
```
Expected output:
```
Wine: /Applications/MetaTrader 5.app/.../wine64
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
Display: gui
Arch: arch -x86_64
```
## Configuration Locations
| Scope | Path | Use Case |
|-------|------|----------|
| Global | `~/.cursor/mcp.json` | Available in all projects |
| Project | `.cursor/mcp.json` | Project-specific tools |
## Troubleshooting
### MCP server not appearing
1. Check MCP panel in Cursor Settings
2. Verify the path is absolute (not relative)
3. Test binary: `/path/to/mt5-quant --help`
4. View MCP logs: Output panel → select "MCP" from dropdown
### Config interpolation
Cursor supports variable substitution in `mcp.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "${userHome}/bin/mt5-quant",
"args": ["--config", "${workspaceFolder}/config.yaml"]
}
}
}
```
Available variables:
- `${userHome}` - Home directory
- `${workspaceFolder}` - Project root
- `${workspaceFolderBasename}` - Project folder name
- `${env:VAR_NAME}` - Environment variable
### Tool not found errors
If the agent says "Tool not found":
1. Check the server is enabled in MCP settings
2. Try disabling and re-enabling the server
3. Restart Cursor
## Resources
- [Cursor MCP Documentation](https://cursor.com/docs/context/mcp)
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
+4 -2
View File
@@ -2,13 +2,15 @@
Full input/output schemas for MT5-Quant tools.
> **Documentation Status:** This file documents 49 of 57 total tools. Missing:
> **Documentation Status:** This file documents 49 of 85 total tools. Missing:
> - `list_experts`, `list_indicators`, `list_scripts`
> - `healthcheck`, `list_symbols`
> - `search_reports`, `get_latest_report`, `list_reports`, `prune_reports`, `tail_log`
> - Reports query: `search_reports`, `get_latest_report`, `list_reports`, `prune_reports`, `tail_log`, `get_report_by_id`, `get_reports_summary`, `get_best_reports`, `search_reports_by_tags`, `search_reports_by_date_range`, `search_reports_by_notes`, `get_reports_by_set_file`, `get_comparable_reports`
> - Granular analytics: `analyze_monthly_pnl`, `analyze_drawdown_events`, `analyze_top_losses`, `analyze_loss_sequences`, `analyze_position_pairs`, `analyze_direction_bias`, `analyze_streaks`, `analyze_concurrent_peak`
> - Deal analytics: `list_deals`, `search_deals_by_comment`, `search_deals_by_magic`, `analyze_profit_distribution`, `analyze_time_performance`, `analyze_hold_time_distribution`, `analyze_layer_performance`, `analyze_volume_vs_profit`, `analyze_costs`, `analyze_efficiency`
> - Archive/history tools: `archive_report`, `archive_all_reports`, `get_history`, `annotate_history`, `promote_to_baseline`
> - Experts search: `search_experts`, `search_indicators`, `search_scripts`, `copy_indicator_to_project`, `copy_script_to_project`
> - Debugging/diagnostics: `diagnose_wine`, `get_mt5_logs`, `search_mt5_errors`, `check_mt5_process`, `kill_mt5_process`, `check_system_resources`, `validate_mt5_config`, `get_wine_prefix_info`, `get_backtest_crash_info`
---
+60 -7
View File
@@ -91,15 +91,68 @@ claude mcp list
### Windsurf
Add to `~/.windsurf/config.yaml`:
```yaml
mcpServers:
mt5-quant:
command: /path/to/mt5-quant
env:
MT5_MCP_HOME: /path/to/mt5-mcp
Add to `~/.codeium/windsurf/mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
```
### Cursor
Add to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` (project):
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
```
Or use Settings → MCP → Add Custom MCP.
### VS Code
Add to `.vscode/mcp.json` in your workspace:
```json
{
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
```
Or run `MCP: Add Server` from Command Palette.
### Antigravity
1. Open Agent panel → Click "..." menu → MCP Servers
2. Click "Manage MCP Servers"
3. Click "View raw config" or "Edit configuration"
4. Add to `mcp_config.json`:
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
```
5. Reload Antigravity to apply changes
## 5. Verify Setup
```bash
+147
View File
@@ -0,0 +1,147 @@
# VS Code MCP Integration Setup
## Quick Setup
### Option 1: Download Prebuilt Binary (Recommended)
```bash
# macOS (Apple Silicon)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-macos-arm64.tar.gz
tar -xzf mt5-quant.tar.gz
# Linux (x64)
curl -L -o mt5-quant.tar.gz https://github.com/masdevid/mt5-mcp/releases/latest/download/mt5-quant-linux-x64.tar.gz
tar -xzf mt5-quant.tar.gz
```
### Option 2: Build from Source
```bash
cargo build --release
```
## Configure VS Code
### Method 1: Command Palette (Recommended)
1. Press `Cmd/Ctrl + Shift + P`
2. Run `MCP: Add Server`
3. Choose **Workspace** or **User** scope
4. Enter server name: `mt5-quant`
5. Enter command: Full path to binary (e.g., `/Users/name/mt5-quant/target/release/mt5-quant`)
### Method 2: Edit mcp.json Directly
Add to `.vscode/mcp.json` in your workspace:
```json
{
"servers": {
"mt5-quant": {
"command": "/absolute/path/to/mt5-quant"
}
}
}
```
Create the file:
```bash
mkdir -p .vscode
cat > .vscode/mcp.json << 'EOF'
{
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
EOF
```
### Method 3: VS Code CLI
```bash
code --add-mcp '{"name":"mt5-quant","command":"/path/to/mt5-quant"}'
```
## Verify Setup
In Copilot chat, type:
```
Run verify_setup
```
Expected output:
```
Wine: /Applications/MetaTrader 5.app/.../wine64
MT5 dir: ~/Library/Application Support/.../MetaTrader 5
Display: gui
Arch: arch -x86_64
```
## Configuration Locations
| Scope | Path | Use Case |
|-------|------|----------|
| Workspace | `.vscode/mcp.json` | Share with team via source control |
| User | `~/.vscode/mcp.json` | Personal tools across all projects |
| Dev Container | `devcontainer.json``customizations.vscode.mcp` | Containerized environments |
## Troubleshooting
### MCP server not appearing
1. Open **Output** panel (`Cmd/Ctrl + Shift + U`)
2. Select **MCP** from dropdown
3. Check for connection errors
4. Verify the path is absolute
### Config not found
The binary auto-detects its config, but you can also:
1. Run `setup.sh` to create `config/mt5-quant.yaml`
2. Or let the binary auto-discover on first run
### Dev Container Setup
Add to `.devcontainer/devcontainer.json`:
```json
{
"customizations": {
"vscode": {
"mcp": {
"servers": {
"mt5-quant": {
"command": "/path/to/mt5-quant"
}
}
}
}
}
}
```
## Key Differences from Other IDEs
VS Code uses `servers` (not `mcpServers`) in the JSON structure:
```json
{
"servers": { // ← VS Code uses "servers"
"mt5-quant": {
"command": "..."
}
}
}
```
Other platforms use `mcpServers`.
## Resources
- [VS Code MCP Documentation](https://code.visualstudio.com/docs/copilot/customization/mcp-servers)
- [MCP Configuration Reference](https://code.visualstudio.com/docs/copilot/reference/mcp-configuration)
- [MT5-Quant Tools Reference](./MCP_TOOLS.md)
+26 -11
View File
@@ -22,14 +22,22 @@ bash scripts/build-rust.sh
### 2. Configure Windsurf
Edit `~/.windsurf/config.yaml`:
Edit `~/.codeium/windsurf/mcp_config.json`:
```yaml
mcpServers:
mt5-quant:
command: /Users/masdevid/jobs/mt5-quant/target/release/mt5-quant
env:
MT5_MCP_HOME: /Users/masdevid/jobs/mt5-quant
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/Users/masdevid/jobs/mt5-quant/target/release/mt5-quant"
}
}
}
```
Or use the automated setup:
```bash
bash scripts/setup.sh
```
### 3. Restart Windsurf
@@ -66,12 +74,19 @@ scp -r config/mt5-quant.yaml user@server:~/.config/mt5-quant/config/
- **NO Python required!**
### Windsurf Config on Target Machine
```yaml
mcpServers:
mt5-quant:
command: /usr/local/bin/mt5-quant
```json
{
"mcpServers": {
"mt5-quant": {
"command": "/usr/local/bin/mt5-quant"
}
}
}
```
The binary auto-detects its config location. No environment variables needed.
## Troubleshooting
### MCP server not appearing
+460 -29
View File
@@ -7,6 +7,16 @@
# --keep-last N Keep only last N backtest reports (default: 20)
# --claude-code Generate CLAUDE.md template and .claude/hooks/user-prompt-submit.sh
# (skips main config wizard — run standalone or alongside normal setup)
#
# MCP Auto-Registration (per official 2025 docs):
# Automatically detects and registers with available MCP platforms:
# - Claude Code : via 'claude mcp add' (stored in ~/.claude.json)
# - Windsurf : ~/.codeium/windsurf/mcp_config.json (JSON, mcpServers)
# - Cursor : ~/.cursor/mcp.json (JSON, mcpServers)
# - VS Code : .vscode/mcp.json (JSON, servers - not mcpServers)
# - Antigravity : mcp_config.json via UI (JSON, mcpServers)
#
# Previous installations are auto-detected and uninstalled before re-registering.
set -uo pipefail
@@ -648,6 +658,30 @@ main() {
_bold "MT5-Quant setup — auto-detecting Wine and MT5 paths"
echo "────────────────────────────────────────────────────"
# ── Check for previous installation ────────────────────────────────────────
if _check_any_existing_registration; then
_yellow "Previous mt5-quant MCP installation detected on one or more platforms"
echo " New path: ${REPO_DIR}/server/main.py"
local reinstall_ans="yes"
if ! $AUTO_YES; then
reinstall_ans=$(_ask "Unregister all previous installations and reinstall?" "yes")
fi
if [[ "$reinstall_ans" =~ ^[Yy] ]]; then
_unregister_all_platforms || {
if ! $AUTO_YES; then
local force_ans
force_ans=$(_ask "Some platforms failed to unregister. Continue anyway?" "no")
[[ ! "$force_ans" =~ ^[Yy] ]] && exit 1
fi
}
else
echo "Aborted — keeping existing installations."
exit 0
fi
fi
# ── Check if config already exists ───────────────────────────────────────
if [[ -f "$CONFIG_OUT" ]] && ! $AUTO_YES; then
_yellow "Config already exists: $CONFIG_OUT"
@@ -784,49 +818,446 @@ main() {
_ok "Written: $CONFIG_OUT"
echo " Tip: see config/example.set for optimization .set file format"
# ── Register with Claude Code ──────────────────────────────────────────────
_offer_mcp_register
# ── Register with all detected MCP platforms ─────────────────────────────
_register_all_mcp_platforms
echo ""
_green "Setup complete!"
echo ""
}
# ── MCP registration ──────────────────────────────────────────────────────────
_offer_mcp_register() {
echo ""
_bold "Registering with Claude Code..."
# ── MCP Platform Detection & Registration ───────────────────────────────────
if ! command -v claude &>/dev/null; then
_warn "claude CLI not found — register manually:"
# Detect available MCP platforms and return them as a list
detect_mcp_platforms() {
local platforms=()
# Claude Code
if command -v claude &>/dev/null; then
platforms+=("claude")
fi
# Windsurf (uses ~/.codeium/windsurf/mcp_config.json)
if [[ -d "$HOME/.codeium/windsurf" ]] || [[ -d "$HOME/.windsurf" ]] || command -v windsurf &>/dev/null; then
platforms+=("windsurf")
fi
# Cursor (uses ~/.cursor/mcp.json)
if [[ -d "$HOME/.cursor" ]] || [[ -d "$HOME/Library/Application Support/Cursor" ]] || command -v cursor &>/dev/null; then
platforms+=("cursor")
fi
# VS Code (uses .vscode/mcp.json or user profile)
if [[ -d "$HOME/.vscode" ]] || [[ -d "$HOME/Library/Application Support/Code" ]] || command -v code &>/dev/null; then
platforms+=("vscode")
fi
printf '%s\n' "${platforms[@]:-}"
}
# Check if mt5-quant is registered on a specific platform
_is_registered_on_platform() {
local platform="$1"
case "$platform" in
claude)
if command -v claude &>/dev/null; then
local mcp_list
mcp_list=$(claude mcp list 2>/dev/null || true)
echo "$mcp_list" | grep -q "mt5-quant"
return $?
fi
return 1
;;
windsurf)
local config_file="$HOME/.codeium/windsurf/mcp_config.json"
[[ -f "$config_file" ]] && grep -q '"mt5-quant"' "$config_file" 2>/dev/null
return $?
;;
cursor)
local config_file
config_file="$HOME/.cursor/mcp.json"
[[ -f "$config_file" ]] && grep -q "mt5-quant" "$config_file" 2>/dev/null
return $?
;;
vscode)
# VS Code uses .vscode/mcp.json in workspace or user profile
local workspace_config=".vscode/mcp.json"
local user_config
user_config="$HOME/.vscode/mcp.json"
[[ -f "$workspace_config" ]] && grep -q '"mt5-quant"' "$workspace_config" 2>/dev/null && return 0
[[ -f "$user_config" ]] && grep -q '"mt5-quant"' "$user_config" 2>/dev/null && return 0
return 1
;;
esac
return 1
}
# Get registered path for a platform
_get_platform_mcp_path() {
local platform="$1"
case "$platform" in
claude)
if command -v claude &>/dev/null; then
local mcp_list
mcp_list=$(claude mcp list 2>/dev/null || true)
echo "$mcp_list" | grep "mt5-quant" | head -1 | sed -E 's/.*--[[:space:]]*//' | tr -d ' '
fi
;;
windsurf)
local config_file="$HOME/.codeium/windsurf/mcp_config.json"
if [[ -f "$config_file" ]]; then
grep -A3 '"mt5-quant"' "$config_file" 2>/dev/null | grep '"command"' | sed 's/.*"command":[[:space:]]*"\([^"]*\)".*/\1/'
fi
;;
cursor)
local config_file="$HOME/.cursor/mcp.json"
if [[ -f "$config_file" ]]; then
grep -A3 '"mt5-quant"' "$config_file" 2>/dev/null | grep '"command"' | sed 's/.*"command":[[:space:]]*"\([^"]*\)".*/\1/'
fi
;;
esac
}
# Unregister from a specific platform
_unregister_from_platform() {
local platform="$1"
echo ""
_bold "Unregistering from $platform..."
case "$platform" in
claude)
if ! command -v claude &>/dev/null; then
_warn "claude CLI not found"
return 1
fi
local out
out=$(claude mcp remove mt5-quant 2>&1) || true
if echo "$out" | grep -qi "removed\|success\|deleted"; then
_ok "Unregistered from Claude Code"
return 0
elif echo "$out" | grep -qi "not found\|does not exist"; then
_ok "No existing registration on Claude Code"
return 0
else
_warn "Unregister result: $out"
return 1
fi
;;
windsurf)
local config_file="$HOME/.codeium/windsurf/mcp_config.json"
if [[ -f "$config_file" ]]; then
# Remove mt5-quant from JSON using Python
if command -v python3 &>/dev/null; then
python3 -c "
import json, sys
with open('$config_file') as f:
data = json.load(f)
if 'mcpServers' in data and 'mt5-quant' in data['mcpServers']:
del data['mcpServers']['mt5-quant']
with open('$config_file', 'w') as f:
json.dump(data, f, indent=2)
" && { _ok "Unregistered from Windsurf"; return 0; }
fi
_warn "Could not auto-unregister from Windsurf — edit $config_file manually"
return 1
fi
_ok "No existing registration on Windsurf"
return 0
;;
cursor)
local config_file="$HOME/.cursor/mcp.json"
if [[ -f "$config_file" ]]; then
# Remove mt5-quant from JSON using Python if available, or sed as fallback
if command -v python3 &>/dev/null; then
python3 -c "
import json, sys
with open('$config_file') as f:
data = json.load(f)
if 'mcpServers' in data and 'mt5-quant' in data['mcpServers']:
del data['mcpServers']['mt5-quant']
with open('$config_file', 'w') as f:
json.dump(data, f, indent=2)
" && { _ok "Unregistered from Cursor"; return 0; }
fi
_warn "Could not auto-unregister from Cursor — edit $config_file manually"
return 1
fi
_ok "No existing registration on Cursor"
return 0
;;
vscode)
# VS Code uses 'servers' (not 'mcpServers') in mcp.json
local workspace_config=".vscode/mcp.json"
local user_config="$HOME/.vscode/mcp.json"
local config_file=""
# Determine which config file to use
if [[ -f "$workspace_config" ]] && grep -q '"mt5-quant"' "$workspace_config" 2>/dev/null; then
config_file="$workspace_config"
elif [[ -f "$user_config" ]] && grep -q '"mt5-quant"' "$user_config" 2>/dev/null; then
config_file="$user_config"
fi
if [[ -n "$config_file" ]]; then
if command -v python3 &>/dev/null; then
python3 -c "
import json, sys
with open('$config_file') as f:
data = json.load(f)
if 'servers' in data and 'mt5-quant' in data['servers']:
del data['servers']['mt5-quant']
with open('$config_file', 'w') as f:
json.dump(data, f, indent=2)
" && { _ok "Unregistered from VS Code ($config_file)"; return 0; }
fi
_warn "Could not auto-unregister from VS Code — edit $config_file manually"
return 1
fi
_ok "No existing registration on VS Code"
return 0
;;
esac
}
# Register with a specific platform
_register_with_platform() {
local platform="$1"
local use_binary="${2:-false}"
# Determine command path (binary for Windsurf/Cursor, Python for Claude)
local cmd_path
if $use_binary && [[ -f "${REPO_DIR}/target/release/mt5-quant" ]]; then
cmd_path="${REPO_DIR}/target/release/mt5-quant"
elif [[ -f "${REPO_DIR}/mt5-quant" ]]; then
cmd_path="${REPO_DIR}/mt5-quant"
else
cmd_path="python3 ${REPO_DIR}/server/main.py"
fi
case "$platform" in
claude)
local out
out=$(claude mcp add mt5-quant -- $cmd_path 2>&1) || true
if echo "$out" | grep -qi "already\|exists"; then
_ok "Already registered on Claude Code"
elif echo "$out" | grep -qi "error\|failed"; then
_warn "Claude Code registration failed: $out"
return 1
else
_ok "Registered on Claude Code"
fi
;;
windsurf)
local config_file="$HOME/.codeium/windsurf/mcp_config.json"
mkdir -p "$(dirname "$config_file")"
# Create or update JSON config
if command -v python3 &>/dev/null; then
python3 -c "
import json, os
config_path = '$config_file'
data = {'mcpServers': {}}
if os.path.exists(config_path):
try:
with open(config_path) as f:
data = json.load(f)
except:
pass
if 'mcpServers' not in data:
data['mcpServers'] = {}
data['mcpServers']['mt5-quant'] = {
'command': '$cmd_path'
}
with open(config_path, 'w') as f:
json.dump(data, f, indent=2)
" && { _ok "Registered on Windsurf ($config_file)"; return 0; }
else
_warn "Python3 required for Windsurf registration"
return 1
fi
;;
cursor)
local config_file="$HOME/.cursor/mcp.json"
mkdir -p "$(dirname "$config_file")"
# Create or update JSON config
if command -v python3 &>/dev/null; then
python3 -c "
import json, os
config_path = '$config_file'
data = {'mcpServers': {}}
if os.path.exists(config_path):
try:
with open(config_path) as f:
data = json.load(f)
except:
pass
if 'mcpServers' not in data:
data['mcpServers'] = {}
data['mcpServers']['mt5-quant'] = {
'command': '$cmd_path'
}
with open(config_path, 'w') as f:
json.dump(data, f, indent=2)
" && { _ok "Registered on Cursor ($config_file)"; return 0; }
else
_warn "Python3 required for Cursor registration"
return 1
fi
;;
vscode)
# VS Code uses .vscode/mcp.json (workspace) or user profile
# Format: { "servers": { "name": { "command": "...", "args": [...], "env": {...} } }
local workspace_config=".vscode/mcp.json"
mkdir -p ".vscode"
if command -v python3 &>/dev/null; then
python3 -c "
import json, os
config_path = '$workspace_config'
data = {'servers': {}}
if os.path.exists(config_path):
try:
with open(config_path) as f:
data = json.load(f)
except:
pass
if 'servers' not in data:
data['servers'] = {}
# For stdio servers, VS Code uses 'command' and optional 'args'
data['servers']['mt5-quant'] = {
'command': '$cmd_path'
}
with open(config_path, 'w') as f:
json.dump(data, f, indent=2)
" && { _ok "Registered on VS Code ($workspace_config)"; return 0; }
else
_warn "Python3 required for VS Code registration"
return 1
fi
;;
esac
}
# Check for any existing registrations across all platforms
_check_any_existing_registration() {
local platforms=()
while IFS= read -r platform; do
[[ -n "$platform" ]] && platforms+=("$platform")
done < <(detect_mcp_platforms)
local found=false
for platform in "${platforms[@]}"; do
if _is_registered_on_platform "$platform"; then
found=true
break
fi
done
$found
}
# Unregister from all platforms
_unregister_all_platforms() {
local platforms=()
while IFS= read -r platform; do
[[ -n "$platform" ]] && platforms+=("$platform")
done < <(detect_mcp_platforms)
for platform in "${platforms[@]}"; do
_is_registered_on_platform "$platform" && _unregister_from_platform "$platform"
done
}
# Main registration function - detects platforms and registers with all
_register_all_mcp_platforms() {
echo ""
_bold "Detecting MCP platforms..."
local platforms=()
while IFS= read -r platform; do
[[ -n "$platform" ]] && platforms+=("$platform")
done < <(detect_mcp_platforms)
if [[ ${#platforms[@]} -eq 0 ]]; then
_warn "No MCP platforms detected (Claude, Windsurf, Cursor, VS Code)"
echo ""
echo " claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
echo " Manual registration required:"
echo " - Claude Code: claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
echo " - Windsurf: Edit ~/.codeium/windsurf/mcp_config.json"
echo " - Cursor: Edit ~/.cursor/mcp.json"
echo " - VS Code: Edit .vscode/mcp.json (workspace) or use MCP: Add Server command"
echo " - Antigravity:Use Agent panel → MCP Servers → Manage → Edit configuration"
echo ""
return
fi
local register=true
if ! $AUTO_YES; then
local ans
ans=$(_ask "Register mt5-quant with Claude Code now?" "yes")
[[ ! "$ans" =~ ^[Yy] ]] && register=false
_ok "Found platforms: ${platforms[*]}"
# Check for existing registrations
local has_existing=false
for platform in "${platforms[@]}"; do
if _is_registered_on_platform "$platform"; then
has_existing=true
local old_path
old_path=$(_get_platform_mcp_path "$platform")
_yellow "Existing registration detected on $platform"
[[ -n "$old_path" ]] && echo " Current path: $old_path"
fi
done
# Prompt for reinstall if any existing registrations found
if $has_existing && ! $AUTO_YES; then
echo ""
local reinstall_ans
reinstall_ans=$(_ask "Unregister existing installations and reinstall on all platforms?" "yes")
if [[ ! "$reinstall_ans" =~ ^[Yy] ]]; then
echo " Keeping existing registrations."
return
fi
fi
if $register; then
local out
out=$(claude mcp add mt5-quant -- python3 "${REPO_DIR}/server/main.py" 2>&1) || true
if echo "$out" | grep -qi "already\|exists"; then
_ok "Already registered (no change needed)"
elif echo "$out" | grep -qi "error\|failed"; then
_warn "Registration failed: $out"
echo " Run manually: claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
else
_ok "Registered: claude mcp add mt5-quant"
fi
else
echo " Skipped. Run manually:"
echo " claude mcp add mt5-quant -- python3 \"${REPO_DIR}/server/main.py\""
fi
# Unregister from all platforms first
for platform in "${platforms[@]}"; do
_is_registered_on_platform "$platform" && _unregister_from_platform "$platform"
done
# Register with all detected platforms
echo ""
_bold "Registering mt5-quant MCP..."
for platform in "${platforms[@]}"; do
# Use binary for Windsurf/Cursor, Python for Claude
case "$platform" in
windsurf|cursor)
_register_with_platform "$platform" true
;;
*)
_register_with_platform "$platform" false
;;
esac
done
echo ""
_green "MCP registration complete!"
echo ""
echo " Registered on: ${platforms[*]}"
echo ""
echo " Config locations:"
[[ " ${platforms[*]} " =~ " claude " ]] && echo " Claude Code: ~/.claude.json (managed via CLI)"
[[ " ${platforms[*]} " =~ " windsurf " ]] && echo " Windsurf: ~/.codeium/windsurf/mcp_config.json"
[[ " ${platforms[*]} " =~ " cursor " ]] && echo " Cursor: ~/.cursor/mcp.json"
[[ " ${platforms[*]} " =~ " vscode " ]] && echo " VS Code: .vscode/mcp.json"
echo ""
echo " Binary: ${REPO_DIR}/target/release/mt5-quant"
echo " Python: ${REPO_DIR}/server/main.py"
echo ""
}
main
+5 -5
View File
@@ -2,18 +2,18 @@
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
"name": "io.github.masdevid/mt5-quant",
"title": "MT5-Quant",
"description": "MCP server for MT5 strategy development. Compile, backtest, analyze MQL5 EAs on macOS/Linux.",
"description": "MCP server for MT5 strategy development with 85 tools. Compile, backtest, analyze, optimize, and debug MQL5 EAs on macOS/Linux. Includes Wine/MT5 crash diagnostics.",
"repository": {
"url": "https://github.com/masdevid/mt5-quant",
"source": "github"
},
"version": "1.29.0",
"version": "1.30.0",
"packages": [
{
"registryType": "mcpb",
"version": "1.29.0",
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.29.0/mcp-mt5-quant-macos-arm64.tar.gz",
"fileSha256": "5d56099e01c6df0c16bc8aa28fffab2e3ba7502f283c3c8273b70e4192230f01",
"version": "1.30.0",
"identifier": "https://github.com/masdevid/mt5-quant/releases/download/v1.30.0/mcp-mt5-quant-macos-arm64.tar.gz",
"fileSha256": "PLACEHOLDER_SHA256",
"transport": {
"type": "stdio"
},
+610 -1
View File
@@ -1,4 +1,4 @@
use chrono::{DateTime, NaiveDateTime};
use chrono::{DateTime, Datelike, NaiveDateTime, Timelike};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -489,3 +489,612 @@ pub struct ConcurrentPeak {
pub peak_open: i32,
pub peak_time: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfitDistribution {
pub small_wins: i32,
pub medium_wins: i32,
pub large_wins: i32,
pub small_losses: i32,
pub medium_losses: i32,
pub large_losses: i32,
pub small_win_pnl: f64,
pub medium_win_pnl: f64,
pub large_win_pnl: f64,
pub small_loss_pnl: f64,
pub medium_loss_pnl: f64,
pub large_loss_pnl: f64,
pub buckets: Vec<ProfitBucket>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfitBucket {
pub range: String,
pub min: f64,
pub max: f64,
pub count: i32,
pub total_pnl: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimePerformance {
pub by_hour: Vec<HourPerformance>,
pub by_day: Vec<DayPerformance>,
pub best_hour: i32,
pub worst_hour: i32,
pub best_day: String,
pub worst_day: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HourPerformance {
pub hour: i32,
pub trades: i32,
pub wins: i32,
pub total_pnl: f64,
pub win_rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DayPerformance {
pub day: String,
pub day_num: i32,
pub trades: i32,
pub wins: i32,
pub total_pnl: f64,
pub win_rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HoldTimeAnalysis {
pub avg_hold_minutes: f64,
pub median_hold_minutes: f64,
pub buckets: Vec<HoldTimeBucket>,
pub correlation_with_profit: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HoldTimeBucket {
pub range: String,
pub min_minutes: f64,
pub max_minutes: f64,
pub count: i32,
pub avg_profit: f64,
pub total_pnl: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayerPerformance {
pub layer: i32,
pub trades: i32,
pub wins: i32,
pub total_pnl: f64,
pub win_rate: f64,
pub avg_volume: f64,
pub avg_profit: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeAnalysis {
pub correlation_with_profit: f64,
pub by_volume_bucket: Vec<VolumeBucket>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VolumeBucket {
pub volume_range: String,
pub min_volume: f64,
pub max_volume: f64,
pub trades: i32,
pub avg_profit: f64,
pub total_pnl: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CostAnalysis {
pub total_commission: f64,
pub total_swap: f64,
pub commission_pct_of_profit: f64,
pub swap_pct_of_profit: f64,
pub avg_commission_per_trade: f64,
pub avg_swap_per_trade: f64,
pub net_profit_before_costs: f64,
pub cost_impact_on_win_rate: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EfficiencyAnalysis {
pub profit_per_hour: f64,
pub profit_per_day: f64,
pub profit_per_trade_hour: f64,
pub avg_trade_duration_hours: f64,
pub annualized_return_pct: f64,
pub trades_per_day: f64,
}
impl DealAnalyzer {
pub fn profit_distribution(&self, deals: &[Deal]) -> ProfitDistribution {
let closed: Vec<&Deal> = deals
.iter()
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
.collect();
let mut small_wins = 0;
let mut medium_wins = 0;
let mut large_wins = 0;
let mut small_losses = 0;
let mut medium_losses = 0;
let mut large_losses = 0;
let mut small_win_pnl = 0.0;
let mut medium_win_pnl = 0.0;
let mut large_win_pnl = 0.0;
let mut small_loss_pnl = 0.0;
let mut medium_loss_pnl = 0.0;
let mut large_loss_pnl = 0.0;
for deal in &closed {
let profit = deal.profit;
if profit > 0.0 {
if profit < 50.0 {
small_wins += 1;
small_win_pnl += profit;
} else if profit < 200.0 {
medium_wins += 1;
medium_win_pnl += profit;
} else {
large_wins += 1;
large_win_pnl += profit;
}
} else {
let loss = profit.abs();
if loss < 50.0 {
small_losses += 1;
small_loss_pnl += profit;
} else if loss < 200.0 {
medium_losses += 1;
medium_loss_pnl += profit;
} else {
large_losses += 1;
large_loss_pnl += profit;
}
}
}
// Create detailed buckets
let bucket_ranges = [
(-999999.0, -500.0, "Loss $500+"),
(-500.0, -200.0, "Loss $200-500"),
(-200.0, -50.0, "Loss $50-200"),
(-50.0, 0.0, "Loss $0-50"),
(0.0, 50.0, "Win $0-50"),
(50.0, 200.0, "Win $50-200"),
(200.0, 500.0, "Win $200-500"),
(500.0, 999999.0, "Win $500+"),
];
let mut buckets: Vec<ProfitBucket> = bucket_ranges
.iter()
.map(|(min, max, range)| {
let count = closed
.iter()
.filter(|d| d.profit >= *min && d.profit < *max)
.count() as i32;
let total_pnl: f64 = closed
.iter()
.filter(|d| d.profit >= *min && d.profit < *max)
.map(|d| d.profit)
.sum();
ProfitBucket {
range: range.to_string(),
min: *min,
max: *max,
count,
total_pnl: (total_pnl * 100.0).round() / 100.0,
}
})
.collect();
// Remove empty buckets
buckets.retain(|b| b.count > 0);
ProfitDistribution {
small_wins,
medium_wins,
large_wins,
small_losses,
medium_losses,
large_losses,
small_win_pnl: (small_win_pnl * 100.0).round() / 100.0,
medium_win_pnl: (medium_win_pnl * 100.0).round() / 100.0,
large_win_pnl: (large_win_pnl * 100.0).round() / 100.0,
small_loss_pnl: (small_loss_pnl * 100.0).round() / 100.0,
medium_loss_pnl: (medium_loss_pnl * 100.0).round() / 100.0,
large_loss_pnl: (large_loss_pnl * 100.0).round() / 100.0,
buckets,
}
}
pub fn time_performance(&self, deals: &[Deal]) -> TimePerformance {
let closed: Vec<&Deal> = deals
.iter()
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
.collect();
let mut hourly: HashMap<i32, (i32, i32, f64)> = HashMap::new();
let mut daily: HashMap<String, (i32, i32, f64, i32)> = HashMap::new();
for deal in &closed {
if let Some(dt) = Self::parse_datetime(&deal.time) {
let hour = dt.hour() as i32;
let day_num = dt.weekday().num_days_from_monday() as i32;
let day_name = match dt.weekday() {
chrono::Weekday::Mon => "Mon",
chrono::Weekday::Tue => "Tue",
chrono::Weekday::Wed => "Wed",
chrono::Weekday::Thu => "Thu",
chrono::Weekday::Fri => "Fri",
chrono::Weekday::Sat => "Sat",
chrono::Weekday::Sun => "Sun",
}.to_string();
let entry = hourly.entry(hour).or_insert((0, 0, 0.0));
entry.0 += 1;
entry.2 += deal.profit;
if deal.profit > 0.0 {
entry.1 += 1;
}
let day_entry = daily.entry(day_name.clone()).or_insert((0, 0, 0.0, day_num));
day_entry.0 += 1;
day_entry.2 += deal.profit;
if deal.profit > 0.0 {
day_entry.1 += 1;
}
}
}
let mut by_hour: Vec<HourPerformance> = hourly
.into_iter()
.map(|(hour, (trades, wins, total_pnl))| HourPerformance {
hour,
trades,
wins,
total_pnl: (total_pnl * 100.0).round() / 100.0,
win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 },
})
.collect();
by_hour.sort_by_key(|h| h.hour);
let mut by_day: Vec<DayPerformance> = daily
.into_iter()
.map(|(day, (trades, wins, total_pnl, day_num))| DayPerformance {
day: day.clone(),
day_num,
trades,
wins,
total_pnl: (total_pnl * 100.0).round() / 100.0,
win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 },
})
.collect();
by_day.sort_by_key(|d| d.day_num);
let best_hour = by_hour.iter().max_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|h| h.hour).unwrap_or(-1);
let worst_hour = by_hour.iter().min_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|h| h.hour).unwrap_or(-1);
let best_day = by_day.iter().max_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|d| d.day.clone()).unwrap_or_default();
let worst_day = by_day.iter().min_by(|a, b| a.total_pnl.partial_cmp(&b.total_pnl).unwrap()).map(|d| d.day.clone()).unwrap_or_default();
TimePerformance {
by_hour,
by_day,
best_hour,
worst_hour,
best_day,
worst_day,
}
}
pub fn hold_time_analysis(&self, deals: &[Deal]) -> HoldTimeAnalysis {
let mut hold_times: Vec<(f64, f64)> = Vec::new(); // (hold_minutes, profit)
let mut open_pos: HashMap<String, DateTime<chrono::Utc>> = HashMap::new();
for deal in deals {
let entry = deal.entry.to_lowercase();
if let Some(dt) = Self::parse_datetime(&deal.time) {
if entry.contains("in") && !entry.contains("out") {
open_pos.insert(deal.order.clone(), dt);
} else if entry.contains("out") && deal.profit != 0.0 {
if let Some(in_time) = open_pos.remove(&deal.order) {
let hold_minutes = (dt - in_time).num_seconds() as f64 / 60.0;
if hold_minutes > 0.0 {
hold_times.push((hold_minutes, deal.profit));
}
}
}
}
}
if hold_times.is_empty() {
return HoldTimeAnalysis {
avg_hold_minutes: 0.0,
median_hold_minutes: 0.0,
buckets: vec![],
correlation_with_profit: 0.0,
};
}
let avg_hold = hold_times.iter().map(|(h, _)| *h).sum::<f64>() / hold_times.len() as f64;
let mut sorted_hold: Vec<f64> = hold_times.iter().map(|(h, _)| *h).collect();
sorted_hold.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median_hold = sorted_hold[sorted_hold.len() / 2];
// Calculate correlation
let n = hold_times.len() as f64;
let sum_x = hold_times.iter().map(|(h, _)| *h).sum::<f64>();
let sum_y = hold_times.iter().map(|(_, p)| *p).sum::<f64>();
let sum_xy = hold_times.iter().map(|(h, p)| h * p).sum::<f64>();
let sum_x2 = hold_times.iter().map(|(h, _)| h * h).sum::<f64>();
let sum_y2 = hold_times.iter().map(|(_, p)| p * p).sum::<f64>();
let correlation = if n > 1.0 {
let numerator = n * sum_xy - sum_x * sum_y;
let denominator = ((n * sum_x2 - sum_x * sum_x) * (n * sum_y2 - sum_y * sum_y)).sqrt();
if denominator > 0.0 { numerator / denominator } else { 0.0 }
} else {
0.0
};
// Create buckets
let bucket_defs = [
(0.0, 15.0, "< 15 min"),
(15.0, 60.0, "15-60 min"),
(60.0, 240.0, "1-4 hours"),
(240.0, 1440.0, "4-24 hours"),
(1440.0, 10080.0, "1-7 days"),
(10080.0, 999999.0, "> 7 days"),
];
let buckets: Vec<HoldTimeBucket> = bucket_defs
.iter()
.map(|(min, max, range)| {
let bucket_deals: Vec<(f64, f64)> = hold_times
.iter()
.filter(|(h, _)| *h >= *min && *h < *max)
.cloned()
.collect();
let count = bucket_deals.len() as i32;
let total_pnl: f64 = bucket_deals.iter().map(|(_, p)| *p).sum();
let avg_profit = if count > 0 { total_pnl / count as f64 } else { 0.0 };
HoldTimeBucket {
range: range.to_string(),
min_minutes: *min,
max_minutes: *max,
count,
avg_profit: (avg_profit * 100.0).round() / 100.0,
total_pnl: (total_pnl * 100.0).round() / 100.0,
}
})
.collect();
HoldTimeAnalysis {
avg_hold_minutes: (avg_hold * 10.0).round() / 10.0,
median_hold_minutes: (median_hold * 10.0).round() / 10.0,
buckets,
correlation_with_profit: (correlation * 1000.0).round() / 1000.0,
}
}
pub fn layer_performance(&self, deals: &[Deal]) -> Vec<LayerPerformance> {
let mut layer_stats: HashMap<i32, (i32, i32, f64, f64)> = HashMap::new();
for deal in deals {
let entry = deal.entry.to_lowercase();
if entry.contains("out") && deal.profit != 0.0 {
let layer = self.extract_layer(&deal.comment);
let stats = layer_stats.entry(layer).or_insert((0, 0, 0.0, 0.0));
stats.0 += 1;
stats.2 += deal.profit;
stats.3 += deal.volume;
if deal.profit > 0.0 {
stats.1 += 1;
}
}
}
let mut result: Vec<LayerPerformance> = layer_stats
.into_iter()
.map(|(layer, (trades, wins, total_pnl, total_volume))| LayerPerformance {
layer,
trades,
wins,
total_pnl: (total_pnl * 100.0).round() / 100.0,
win_rate: if trades > 0 { (wins as f64 / trades as f64 * 1000.0).round() / 10.0 } else { 0.0 },
avg_volume: if trades > 0 { (total_volume / trades as f64 * 10000.0).round() / 10000.0 } else { 0.0 },
avg_profit: if trades > 0 { (total_pnl / trades as f64 * 100.0).round() / 100.0 } else { 0.0 },
})
.collect();
result.sort_by_key(|l| l.layer);
result
}
pub fn volume_analysis(&self, deals: &[Deal]) -> VolumeAnalysis {
let closed: Vec<&Deal> = deals
.iter()
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
.collect();
if closed.is_empty() {
return VolumeAnalysis {
correlation_with_profit: 0.0,
by_volume_bucket: vec![],
};
}
// Calculate correlation
let n = closed.len() as f64;
let sum_x: f64 = closed.iter().map(|d| d.volume).sum();
let sum_y: f64 = closed.iter().map(|d| d.profit).sum();
let sum_xy: f64 = closed.iter().map(|d| d.volume * d.profit).sum();
let sum_x2: f64 = closed.iter().map(|d| d.volume * d.volume).sum();
let sum_y2: f64 = closed.iter().map(|d| d.profit * d.profit).sum();
let correlation = if n > 1.0 {
let numerator = n * sum_xy - sum_x * sum_y;
let denominator = ((n * sum_x2 - sum_x * sum_x) * (n * sum_y2 - sum_y * sum_y)).sqrt();
if denominator > 0.0 { numerator / denominator } else { 0.0 }
} else {
0.0
};
// Create volume buckets
let bucket_defs = [
(0.0, 0.1, "0.0-0.1 lots"),
(0.1, 0.5, "0.1-0.5 lots"),
(0.5, 1.0, "0.5-1.0 lots"),
(1.0, 2.0, "1.0-2.0 lots"),
(2.0, 5.0, "2.0-5.0 lots"),
(5.0, 999.0, "5.0+ lots"),
];
let by_volume_bucket: Vec<VolumeBucket> = bucket_defs
.iter()
.map(|(min, max, range)| {
let bucket_deals: Vec<&Deal> = closed
.iter()
.filter(|d| d.volume >= *min && d.volume < *max)
.cloned()
.collect();
let trades = bucket_deals.len() as i32;
let total_pnl: f64 = bucket_deals.iter().map(|d| d.profit).sum();
let avg_profit = if trades > 0 { total_pnl / trades as f64 } else { 0.0 };
VolumeBucket {
volume_range: range.to_string(),
min_volume: *min,
max_volume: *max,
trades,
avg_profit: (avg_profit * 100.0).round() / 100.0,
total_pnl: (total_pnl * 100.0).round() / 100.0,
}
})
.collect();
VolumeAnalysis {
correlation_with_profit: (correlation * 1000.0).round() / 1000.0,
by_volume_bucket,
}
}
pub fn cost_analysis(&self, deals: &[Deal]) -> CostAnalysis {
let total_commission: f64 = deals.iter().map(|d| d.commission.abs()).sum();
let total_swap: f64 = deals.iter().map(|d| d.swap.abs()).sum();
let gross_profit: f64 = deals.iter().map(|d| d.profit).filter(|p| *p > 0.0).sum();
let trade_count = deals.iter().filter(|d| d.entry.to_lowercase().contains("out")).count() as f64;
let commission_pct = if gross_profit > 0.0 { (total_commission / gross_profit * 10000.0).round() / 100.0 } else { 0.0 };
let swap_pct = if gross_profit > 0.0 { (total_swap / gross_profit * 10000.0).round() / 100.0 } else { 0.0 };
// Calculate what win rate would be without costs
let wins_before_costs = deals
.iter()
.filter(|d| {
let profit_before_costs = d.profit + d.commission.abs() + d.swap.abs();
d.entry.to_lowercase().contains("out") && profit_before_costs > 0.0
})
.count() as f64;
let total_closed = deals.iter().filter(|d| d.entry.to_lowercase().contains("out")).count() as f64;
let win_rate_before_costs = if total_closed > 0.0 { wins_before_costs / total_closed * 100.0 } else { 0.0 };
let current_wins = deals.iter().filter(|d| d.entry.to_lowercase().contains("out") && d.profit > 0.0).count() as f64;
let current_win_rate = if total_closed > 0.0 { current_wins / total_closed * 100.0 } else { 0.0 };
CostAnalysis {
total_commission: (total_commission * 100.0).round() / 100.0,
total_swap: (total_swap * 100.0).round() / 100.0,
commission_pct_of_profit: commission_pct,
swap_pct_of_profit: swap_pct,
avg_commission_per_trade: if trade_count > 0.0 { (total_commission / trade_count * 100.0).round() / 100.0 } else { 0.0 },
avg_swap_per_trade: if trade_count > 0.0 { (total_swap / trade_count * 100.0).round() / 100.0 } else { 0.0 },
net_profit_before_costs: (gross_profit * 100.0).round() / 100.0,
cost_impact_on_win_rate: (win_rate_before_costs - current_win_rate * 100.0).round() / 100.0,
}
}
pub fn efficiency_analysis(&self, deals: &[Deal], _metrics: &Metrics) -> EfficiencyAnalysis {
let closed: Vec<&Deal> = deals
.iter()
.filter(|d| d.entry.to_lowercase().contains("out") && d.profit != 0.0)
.collect();
if closed.is_empty() {
return EfficiencyAnalysis {
profit_per_hour: 0.0,
profit_per_day: 0.0,
profit_per_trade_hour: 0.0,
avg_trade_duration_hours: 0.0,
annualized_return_pct: 0.0,
trades_per_day: 0.0,
};
}
let total_profit: f64 = closed.iter().map(|d| d.profit).sum();
let total_trades = closed.len() as f64;
// Calculate total hold time
let mut total_hold_minutes = 0.0;
let mut open_pos: HashMap<String, DateTime<chrono::Utc>> = HashMap::new();
for deal in deals {
let entry = deal.entry.to_lowercase();
if let Some(dt) = Self::parse_datetime(&deal.time) {
if entry.contains("in") && !entry.contains("out") {
open_pos.insert(deal.order.clone(), dt);
} else if entry.contains("out") && deal.profit != 0.0 {
if let Some(in_time) = open_pos.remove(&deal.order) {
let hold_minutes = (dt - in_time).num_seconds() as f64 / 60.0;
if hold_minutes > 0.0 {
total_hold_minutes += hold_minutes;
}
}
}
}
}
let total_hold_hours = total_hold_minutes / 60.0;
let avg_trade_duration = if total_trades > 0.0 { total_hold_minutes / total_trades / 60.0 } else { 0.0 };
// Get date range
let dates: Vec<DateTime<chrono::Utc>> = deals
.iter()
.filter_map(|d| Self::parse_datetime(&d.time))
.collect();
let total_days = if dates.len() >= 2 {
let min_date = dates.iter().min().unwrap();
let max_date = dates.iter().max().unwrap();
(*max_date - *min_date).num_days().max(1) as f64
} else {
1.0
};
// Use a default deposit of 10000 for annualized calculation
// In real scenarios, this should come from the report
let deposit = 10000.0;
let annualized = if total_days > 0.0 && deposit > 0.0 {
let daily_return = total_profit / deposit / total_days;
((1.0 + daily_return).powf(365.0) - 1.0) * 100.0
} else {
0.0
};
EfficiencyAnalysis {
profit_per_hour: if total_hold_hours > 0.0 { (total_profit / total_hold_hours * 100.0).round() / 100.0 } else { 0.0 },
profit_per_day: (total_profit / total_days * 100.0).round() / 100.0,
profit_per_trade_hour: if total_hold_hours > 0.0 { (total_profit / total_hold_hours / total_trades * 100.0).round() / 100.0 } else { 0.0 },
avg_trade_duration_hours: (avg_trade_duration * 10.0).round() / 10.0,
annualized_return_pct: (annualized * 10.0).round() / 10.0,
trades_per_day: (total_trades / total_days * 10.0).round() / 10.0,
}
}
}
+539
View File
@@ -374,4 +374,543 @@ impl ReportDb {
Ok(entry)
}
/// Get a specific report by ID
pub fn get_by_id(&self, id: &str) -> Result<Option<ReportEntry>> {
let conn = self.connect()?;
let mut stmt = conn.prepare(
"SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE id = ?"
)?;
let entry = stmt
.query_map([id], |row| {
let tags_json: String = row.get(23)?;
let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.next();
Ok(entry)
}
/// Search reports by tags (at least one tag must match)
pub fn search_by_tags(&self, tags: &[String], limit: usize) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE 1=1"
.to_string();
// Build OR conditions for tags - use JSON1 extension for tag matching
if !tags.is_empty() {
let tag_conditions: Vec<String> = tags.iter()
.map(|tag| format!("tags LIKE '%{}%'", tag.replace("'", "''")))
.collect();
sql.push_str(&format!(" AND ({})", tag_conditions.join(" OR ")));
}
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
let mut stmt = conn.prepare(&sql)?;
let entries: Vec<ReportEntry> = stmt
.query_map([], |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Search reports by notes text (case-insensitive LIKE)
pub fn search_by_notes(&self, query: &str, limit: usize) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let pattern = format!("%{}%", query.replace("'", "''"));
let mut stmt = conn.prepare(
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE notes LIKE '{}' ORDER BY created_at DESC LIMIT {}",
pattern, limit)
)?;
let entries: Vec<ReportEntry> = stmt
.query_map([], |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Find reports by set file (original or snapshot)
pub fn search_by_set_file(&self, set_file: &str, limit: usize) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let pattern = format!("%{}%", set_file.replace("'", "''"));
let mut stmt = conn.prepare(
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE (set_file_original LIKE '{}' OR set_snapshot_path LIKE '{}') \
ORDER BY created_at DESC LIMIT {}",
pattern, pattern, limit)
)?;
let entries: Vec<ReportEntry> = stmt
.query_map([], |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Search by backtest date range (from_date and to_date fields)
pub fn search_by_date_range(
&self,
from_start: Option<&str>,
from_end: Option<&str>,
to_start: Option<&str>,
to_end: Option<&str>,
limit: usize,
) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE 1=1"
.to_string();
let mut params: Vec<String> = Vec::new();
if let Some(start) = from_start {
sql.push_str(" AND from_date >= ?");
params.push(start.to_string());
}
if let Some(end) = from_end {
sql.push_str(" AND from_date <= ?");
params.push(end.to_string());
}
if let Some(start) = to_start {
sql.push_str(" AND to_date >= ?");
params.push(start.to_string());
}
if let Some(end) = to_end {
sql.push_str(" AND to_date <= ?");
params.push(end.to_string());
}
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
let mut stmt = conn.prepare(&sql)?;
let entries: Vec<ReportEntry> = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Get comparable reports (same expert/symbol/timeframe for comparison)
pub fn get_comparable(
&self,
expert: &str,
symbol: &str,
timeframe: &str,
exclude_id: Option<&str>,
limit: usize,
) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE expert = ? AND symbol = ? AND timeframe = ?"
.to_string();
let mut params: Vec<String> = vec![
expert.to_string(),
symbol.to_string(),
timeframe.to_string(),
];
if let Some(id) = exclude_id {
sql.push_str(" AND id != ?");
params.push(id.to_string());
}
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
let mut stmt = conn.prepare(&sql)?;
let entries: Vec<ReportEntry> = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Get reports sorted by a specific metric (for best/worst queries)
pub fn get_sorted_by(
&self,
sort_column: &str,
ascending: bool,
limit: usize,
filters: &ReportFilters,
) -> Result<Vec<ReportEntry>> {
let conn = self.connect()?;
// Validate sort column to prevent SQL injection
let valid_columns = ["net_profit", "profit_factor", "max_dd_pct", "win_rate_pct",
"sharpe_ratio", "recovery_factor", "total_trades", "created_at"];
if !valid_columns.contains(&sort_column) {
return Err(anyhow::anyhow!("Invalid sort column: {}", sort_column));
}
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
win_rate_pct, recovery_factor, deposit, currency, leverage, \
duration_seconds, tags, notes, verdict \
FROM reports WHERE 1=1"
.to_string();
let mut filter_params: Vec<String> = Vec::new();
if let Some(ea) = &filters.expert {
sql.push_str(" AND expert LIKE ?");
filter_params.push(format!("%{}%", ea));
}
if let Some(sym) = &filters.symbol {
sql.push_str(" AND symbol = ?");
filter_params.push(sym.clone());
}
if let Some(tf) = &filters.timeframe {
sql.push_str(" AND timeframe = ?");
filter_params.push(tf.clone());
}
if let Some(verdict) = &filters.verdict {
sql.push_str(" AND verdict = ?");
filter_params.push(verdict.clone());
}
// For non-created_at columns, only include rows where that column is not NULL
if sort_column != "created_at" {
sql.push_str(&format!(" AND {} IS NOT NULL", sort_column));
}
let order = if ascending { "ASC" } else { "DESC" };
sql.push_str(&format!(" ORDER BY {} {} LIMIT {}", sort_column, order, limit));
let mut stmt = conn.prepare(&sql)?;
let entries: Vec<ReportEntry> = stmt
.query_map(rusqlite::params_from_iter(filter_params.iter()), |row| {
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(ReportEntry {
id: row.get(0)?,
expert: row.get(1)?,
symbol: row.get(2)?,
timeframe: row.get(3)?,
model: row.get(4)?,
from_date: row.get(5)?,
to_date: row.get(6)?,
created_at: row.get(7)?,
set_file_original: row.get(8)?,
set_snapshot_path: row.get(9)?,
report_dir: row.get(10)?,
charts_dir: row.get(11)?,
net_profit: row.get(12)?,
profit_factor: row.get(13)?,
max_dd_pct: row.get(14)?,
sharpe_ratio: row.get(15)?,
total_trades: row.get(16)?,
win_rate_pct: row.get(17)?,
recovery_factor: row.get(18)?,
deposit: row.get(19)?,
currency: row.get(20)?,
leverage: row.get(21)?,
duration_seconds: row.get(22)?,
tags,
notes: row.get(24)?,
verdict: row.get(25)?,
})
})?
.filter_map(|r| r.ok())
.collect();
Ok(entries)
}
/// Get aggregate statistics for reports
pub fn get_stats(&self, filters: &ReportFilters) -> Result<ReportStats> {
let conn = self.connect()?;
let mut sql = "SELECT \
COUNT(*), \
AVG(net_profit), \
AVG(profit_factor), \
AVG(max_dd_pct), \
AVG(win_rate_pct), \
AVG(sharpe_ratio), \
SUM(CASE WHEN net_profit > 0 THEN 1 ELSE 0 END), \
SUM(CASE WHEN verdict = 'pass' THEN 1 ELSE 0 END), \
SUM(CASE WHEN verdict = 'fail' THEN 1 ELSE 0 END), \
SUM(CASE WHEN verdict = 'marginal' THEN 1 ELSE 0 END) \
FROM reports WHERE 1=1"
.to_string();
let mut filter_params: Vec<String> = Vec::new();
if let Some(ea) = &filters.expert {
sql.push_str(" AND expert LIKE ?");
filter_params.push(format!("%{}%", ea));
}
if let Some(sym) = &filters.symbol {
sql.push_str(" AND symbol = ?");
filter_params.push(sym.clone());
}
if let Some(tf) = &filters.timeframe {
sql.push_str(" AND timeframe = ?");
filter_params.push(tf.clone());
}
if let Some(verdict) = &filters.verdict {
sql.push_str(" AND verdict = ?");
filter_params.push(verdict.clone());
}
let stats: ReportStats = conn.query_row(
&sql,
rusqlite::params_from_iter(filter_params.iter()),
|row| {
let total: i64 = row.get(0)?;
let profitable: Option<i64> = row.get(6)?;
let pass_count: Option<i64> = row.get(7)?;
let fail_count: Option<i64> = row.get(8)?;
let marginal_count: Option<i64> = row.get(9)?;
Ok(ReportStats {
total_count: total as usize,
avg_net_profit: row.get(1)?,
avg_profit_factor: row.get(2)?,
avg_max_dd_pct: row.get(3)?,
avg_win_rate_pct: row.get(4)?,
avg_sharpe_ratio: row.get(5)?,
profitable_count: profitable.unwrap_or(0) as usize,
pass_verdict_count: pass_count.unwrap_or(0) as usize,
fail_verdict_count: fail_count.unwrap_or(0) as usize,
marginal_verdict_count: marginal_count.unwrap_or(0) as usize,
})
},
)?;
Ok(stats)
}
}
#[derive(Debug, Serialize, Deserialize, Default)]
pub struct ReportStats {
pub total_count: usize,
pub avg_net_profit: Option<f64>,
pub avg_profit_factor: Option<f64>,
pub avg_max_dd_pct: Option<f64>,
pub avg_win_rate_pct: Option<f64>,
pub avg_sharpe_ratio: Option<f64>,
pub profitable_count: usize,
pub pass_verdict_count: usize,
pub fail_verdict_count: usize,
pub marginal_verdict_count: usize,
}
+152
View File
@@ -126,3 +126,155 @@ pub fn tool_analyze_concurrent_peak() -> Value {
}
})
}
pub fn tool_list_deals() -> Value {
json!({
"name": "list_deals",
"description": "List individual deals from a backtest report with optional filters",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" },
"deal_type": { "type": "string", "enum": ["buy", "sell"], "description": "Filter by deal type" },
"min_profit": { "type": "number", "description": "Minimum profit (use negative for losses)" },
"max_profit": { "type": "number", "description": "Maximum profit" },
"start_date": { "type": "string", "description": "Start date filter (YYYY.MM.DD)" },
"end_date": { "type": "string", "description": "End date filter (YYYY.MM.DD)" },
"min_volume": { "type": "number", "description": "Minimum volume/lots" },
"max_volume": { "type": "number", "description": "Maximum volume/lots" },
"limit": { "type": "integer", "default": 100, "description": "Max deals to return" }
}
}
})
}
pub fn tool_search_deals_by_comment() -> Value {
json!({
"name": "search_deals_by_comment",
"description": "Search deals by comment text (case-insensitive partial match)",
"inputSchema": {
"type": "object",
"required": ["report_dir", "query"],
"properties": {
"report_dir": { "type": "string" },
"query": { "type": "string", "description": "Search text in comments" },
"limit": { "type": "integer", "default": 50 }
}
}
})
}
pub fn tool_search_deals_by_magic() -> Value {
json!({
"name": "search_deals_by_magic",
"description": "Filter deals by magic number (EA identifier)",
"inputSchema": {
"type": "object",
"required": ["report_dir", "magic"],
"properties": {
"report_dir": { "type": "string" },
"magic": { "type": "string", "description": "Magic number to filter by" },
"limit": { "type": "integer", "default": 100 }
}
}
})
}
pub fn tool_analyze_profit_distribution() -> Value {
json!({
"name": "analyze_profit_distribution",
"description": "Analyze profit distribution - small/medium/large wins and losses with detailed buckets",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
pub fn tool_analyze_time_performance() -> Value {
json!({
"name": "analyze_time_performance",
"description": "Analyze performance by hour of day and day of week",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
pub fn tool_analyze_hold_time_distribution() -> Value {
json!({
"name": "analyze_hold_time_distribution",
"description": "Analyze hold time distribution and correlation with profit",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
pub fn tool_analyze_layer_performance() -> Value {
json!({
"name": "analyze_layer_performance",
"description": "Analyze performance by grid layer (extracted from deal comments)",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
pub fn tool_analyze_volume_vs_profit() -> Value {
json!({
"name": "analyze_volume_vs_profit",
"description": "Analyze correlation between volume and profit, plus performance by volume bucket",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
pub fn tool_analyze_costs() -> Value {
json!({
"name": "analyze_costs",
"description": "Analyze commission and swap costs impact on profitability",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
pub fn tool_analyze_efficiency() -> Value {
json!({
"name": "analyze_efficiency",
"description": "Calculate efficiency metrics: profit per hour/day, annualized return, trade frequency",
"inputSchema": {
"type": "object",
"required": ["report_dir"],
"properties": {
"report_dir": { "type": "string" }
}
}
})
}
+31 -2
View File
@@ -22,7 +22,7 @@ pub fn get_tools_list() -> Value {
optimization::tool_get_optimization_status(),
optimization::tool_get_optimization_results(),
optimization::tool_list_jobs(),
// Analytics (9 tools)
// Analytics (19 tools)
analytics::tool_analyze_report(),
analytics::tool_analyze_monthly_pnl(),
analytics::tool_analyze_drawdown_events(),
@@ -32,6 +32,17 @@ pub fn get_tools_list() -> Value {
analytics::tool_analyze_direction_bias(),
analytics::tool_analyze_streaks(),
analytics::tool_analyze_concurrent_peak(),
// Deal query tools (10)
analytics::tool_list_deals(),
analytics::tool_search_deals_by_comment(),
analytics::tool_search_deals_by_magic(),
analytics::tool_analyze_profit_distribution(),
analytics::tool_analyze_time_performance(),
analytics::tool_analyze_hold_time_distribution(),
analytics::tool_analyze_layer_performance(),
analytics::tool_analyze_volume_vs_profit(),
analytics::tool_analyze_costs(),
analytics::tool_analyze_efficiency(),
// Baseline
baseline::tool_compare_baseline(),
// Experts
@@ -58,6 +69,16 @@ pub fn get_tools_list() -> Value {
utility::tool_check_mt5_status(),
utility::tool_create_set_template(),
utility::tool_export_report(),
// Debugging/Diagnostics (10 tools)
utility::tool_diagnose_wine(),
utility::tool_get_mt5_logs(),
utility::tool_search_mt5_errors(),
utility::tool_check_mt5_process(),
utility::tool_kill_mt5_process(),
utility::tool_check_system_resources(),
utility::tool_validate_mt5_config(),
utility::tool_get_wine_prefix_info(),
utility::tool_get_backtest_crash_info(),
// Set Files
setfiles::tool_read_set_file(),
setfiles::tool_write_set_file(),
@@ -67,7 +88,7 @@ pub fn get_tools_list() -> Value {
setfiles::tool_describe_sweep(),
setfiles::tool_list_set_files(),
setfiles::tool_set_from_optimization(),
// Reports (11 tools)
// Reports (19 tools)
reports::tool_list_reports(),
reports::tool_search_reports(),
reports::tool_get_latest_report(),
@@ -78,6 +99,14 @@ pub fn get_tools_list() -> Value {
reports::tool_get_history(),
reports::tool_promote_to_baseline(),
reports::tool_annotate_history(),
reports::tool_get_report_by_id(),
reports::tool_get_reports_summary(),
reports::tool_get_best_reports(),
reports::tool_search_reports_by_tags(),
reports::tool_search_reports_by_date_range(),
reports::tool_search_reports_by_notes(),
reports::tool_get_reports_by_set_file(),
reports::tool_get_comparable_reports(),
];
serde_json::json!(tools)
+130
View File
@@ -148,3 +148,133 @@ pub fn tool_prune_reports() -> Value {
}
})
}
pub fn tool_get_report_by_id() -> Value {
json!({
"name": "get_report_by_id",
"description": "Get a specific report by its ID with full details and optional equity chart",
"inputSchema": {
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "string", "description": "Report ID (from list_reports or search_reports)" },
"include_chart": { "type": "boolean", "description": "Include equity chart as base64 PNG (default: true)", "default": true }
}
}
})
}
pub fn tool_get_reports_summary() -> Value {
json!({
"name": "get_reports_summary",
"description": "Get aggregate statistics across reports - counts, averages by EA/symbol/timeframe/verdict",
"inputSchema": {
"type": "object",
"properties": {
"expert": { "type": "string", "description": "Filter by EA name substring" },
"symbol": { "type": "string", "description": "Filter by exact symbol" },
"timeframe": { "type": "string", "description": "Filter by exact timeframe" },
"verdict": { "type": "string", "description": "Filter by verdict (pass/fail/marginal)" }
}
}
})
}
pub fn tool_get_best_reports() -> Value {
json!({
"name": "get_best_reports",
"description": "Get top N reports sorted by performance metric (profit factor, win rate, drawdown, etc.)",
"inputSchema": {
"type": "object",
"properties": {
"sort_by": { "type": "string", "enum": ["net_profit", "profit_factor", "max_dd_pct", "win_rate_pct", "sharpe_ratio", "recovery_factor", "total_trades"], "default": "profit_factor", "description": "Metric to sort by" },
"order": { "type": "string", "enum": ["asc", "desc"], "default": "desc", "description": "Sort order (use 'asc' for drawdown, 'desc' for profit)" },
"limit": { "type": "integer", "default": 10, "description": "Number of reports to return" },
"expert": { "type": "string", "description": "Filter by EA name substring" },
"symbol": { "type": "string", "description": "Filter by exact symbol" },
"timeframe": { "type": "string", "description": "Filter by exact timeframe" },
"verdict": { "type": "string", "description": "Filter by verdict" }
}
}
})
}
pub fn tool_search_reports_by_tags() -> Value {
json!({
"name": "search_reports_by_tags",
"description": "Search reports by tags - at least one tag must match (OR logic)",
"inputSchema": {
"type": "object",
"required": ["tags"],
"properties": {
"tags": { "type": "array", "items": { "type": "string" }, "description": "Tags to search for (e.g., ['production', 'verified'])" },
"limit": { "type": "integer", "default": 50 }
}
}
})
}
pub fn tool_search_reports_by_date_range() -> Value {
json!({
"name": "search_reports_by_date_range",
"description": "Search reports by backtest date range (from_date and to_date fields)",
"inputSchema": {
"type": "object",
"properties": {
"from_start": { "type": "string", "description": "From date >= this (YYYY.MM.DD)" },
"from_end": { "type": "string", "description": "From date <= this (YYYY.MM.DD)" },
"to_start": { "type": "string", "description": "To date >= this (YYYY.MM.DD)" },
"to_end": { "type": "string", "description": "To date <= this (YYYY.MM.DD)" },
"limit": { "type": "integer", "default": 50 }
}
}
})
}
pub fn tool_search_reports_by_notes() -> Value {
json!({
"name": "search_reports_by_notes",
"description": "Full-text search in report notes field (case-insensitive LIKE search)",
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": { "type": "string", "description": "Search text (partial match)" },
"limit": { "type": "integer", "default": 50 }
}
}
})
}
pub fn tool_get_reports_by_set_file() -> Value {
json!({
"name": "get_reports_by_set_file",
"description": "Find all reports that used a specific .set parameter file",
"inputSchema": {
"type": "object",
"required": ["set_file"],
"properties": {
"set_file": { "type": "string", "description": "Set filename or partial path to match" },
"limit": { "type": "integer", "default": 50 }
}
}
})
}
pub fn tool_get_comparable_reports() -> Value {
json!({
"name": "get_comparable_reports",
"description": "Find reports comparable to a given report (same EA, symbol, timeframe) - useful for before/after analysis",
"inputSchema": {
"type": "object",
"properties": {
"report_id": { "type": "string", "description": "Reference report ID (if provided, uses its expert/symbol/timeframe)" },
"expert": { "type": "string", "description": "EA name (required if report_id not provided)" },
"symbol": { "type": "string", "description": "Symbol (required if report_id not provided)" },
"timeframe": { "type": "string", "description": "Timeframe (required if report_id not provided)" },
"exclude_id": { "type": "string", "description": "Exclude this report ID from results" },
"limit": { "type": "integer", "default": 20 }
}
}
})
}
+146
View File
@@ -165,3 +165,149 @@ pub fn tool_export_report() -> Value {
}
})
}
pub fn tool_diagnose_wine() -> Value {
json!({
"name": "diagnose_wine",
"description": "Check Wine installation, version, and prefix health. Reports errors, warnings, and recent Wine errors.",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_get_mt5_logs() -> Value {
json!({
"name": "get_mt5_logs",
"description": "Get MT5 terminal, tester, or MetaEditor logs with optional search filtering",
"inputSchema": {
"type": "object",
"properties": {
"log_type": {
"type": "string",
"enum": ["terminal", "tester", "metaeditor"],
"default": "terminal",
"description": "Type of log to retrieve"
},
"lines": {
"type": "integer",
"default": 100,
"description": "Number of lines to return (from end of log)"
},
"search": {
"type": "string",
"description": "Optional search term to filter log lines"
}
}
}
})
}
pub fn tool_search_mt5_errors() -> Value {
json!({
"name": "search_mt5_errors",
"description": "Search MT5 logs for error patterns (error, failed, crash, exception, etc.) in recent hours",
"inputSchema": {
"type": "object",
"properties": {
"hours_back": {
"type": "integer",
"default": 24,
"description": "Hours to search back in logs"
},
"max_errors": {
"type": "integer",
"default": 50,
"description": "Maximum number of errors to return"
}
}
}
})
}
pub fn tool_check_mt5_process() -> Value {
json!({
"name": "check_mt5_process",
"description": "Check if MT5 processes are running, get process info (PID, CPU, memory usage)",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_kill_mt5_process() -> Value {
json!({
"name": "kill_mt5_process",
"description": "Kill stuck MT5 processes. Use force=true for stuck wineserver.",
"inputSchema": {
"type": "object",
"properties": {
"pid": {
"type": "string",
"description": "Optional specific PID to kill"
},
"force": {
"type": "boolean",
"default": false,
"description": "Use SIGKILL (-9) instead of SIGTERM (-15), also kills wineserver"
}
}
}
})
}
pub fn tool_check_system_resources() -> Value {
json!({
"name": "check_system_resources",
"description": "Check disk space, memory, and CPU. Warns if resources are low for MT5 operations.",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_validate_mt5_config() -> Value {
json!({
"name": "validate_mt5_config",
"description": "Validate MT5 configuration files (terminal.ini, tester settings). Reports errors and warnings.",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_get_wine_prefix_info() -> Value {
json!({
"name": "get_wine_prefix_info",
"description": "Get detailed Wine prefix information: Windows version, installed programs, registry files, drive_c size",
"inputSchema": {
"type": "object"
}
})
}
pub fn tool_get_backtest_crash_info() -> Value {
json!({
"name": "get_backtest_crash_info",
"description": "Investigate backtest crashes/failures. Checks for incomplete markers, missing deals.csv, error logs. Can scan recent reports.",
"inputSchema": {
"type": "object",
"properties": {
"report_dir": {
"type": "string",
"description": "Optional specific report directory to check"
},
"check_recent": {
"type": "boolean",
"default": true,
"description": "Also check recent reports for failures"
},
"hours_back": {
"type": "integer",
"default": 6,
"description": "Hours back to check for recent failures"
}
}
}
})
}
+331
View File
@@ -305,5 +305,336 @@ pub async fn handle_analyze_concurrent_peak(_config: &Config, args: &Value) -> R
}))
}
// === Deal Query Handlers ===
pub async fn handle_list_deals(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
// Apply filters
let deal_type = args.get("deal_type").and_then(|v| v.as_str());
let min_profit = args.get("min_profit").and_then(|v| v.as_f64());
let max_profit = args.get("max_profit").and_then(|v| v.as_f64());
let start_date = args.get("start_date").and_then(|v| v.as_str());
let end_date = args.get("end_date").and_then(|v| v.as_str());
let min_volume = args.get("min_volume").and_then(|v| v.as_f64());
let max_volume = args.get("max_volume").and_then(|v| v.as_f64());
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
let mut filtered: Vec<&Deal> = deals.iter().filter(|d| {
// Only include closed trades with non-zero profit
if !d.entry.to_lowercase().contains("out") || d.profit == 0.0 {
return false;
}
if let Some(dt) = deal_type {
if !d.deal_type.to_lowercase().contains(dt) {
return false;
}
}
if let Some(min) = min_profit {
if d.profit < min {
return false;
}
}
if let Some(max) = max_profit {
if d.profit > max {
return false;
}
}
if let Some(start) = start_date {
if !d.time.starts_with(start) && d.time < start.to_string() {
return false;
}
}
if let Some(end) = end_date {
if d.time > end.to_string() {
return false;
}
}
if let Some(min) = min_volume {
if d.volume < min {
return false;
}
}
if let Some(max) = max_volume {
if d.volume > max {
return false;
}
}
true
}).collect();
// Sort by time descending
filtered.sort_by(|a, b| b.time.cmp(&a.time));
filtered.truncate(limit);
let deal_list: Vec<Value> = filtered
.iter()
.map(|d| json!({
"time": d.time,
"deal": d.deal,
"symbol": d.symbol,
"deal_type": d.deal_type,
"volume": d.volume,
"price": d.price,
"profit": d.profit,
"commission": d.commission,
"swap": d.swap,
"comment": d.comment,
"magic": d.magic,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"total_deals": deals.len(),
"filtered_count": deal_list.len(),
"deals": deal_list,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_search_deals_by_comment(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let query = args.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("query is required"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let (deals, _) = load_report_data(report_dir)?;
let query_lower = query.to_lowercase();
let mut filtered: Vec<&Deal> = deals
.iter()
.filter(|d| {
d.entry.to_lowercase().contains("out")
&& d.profit != 0.0
&& d.comment.to_lowercase().contains(&query_lower)
})
.collect();
filtered.sort_by(|a, b| b.time.cmp(&a.time));
filtered.truncate(limit);
let deal_list: Vec<Value> = filtered
.iter()
.map(|d| json!({
"time": d.time,
"deal": d.deal,
"symbol": d.symbol,
"deal_type": d.deal_type,
"volume": d.volume,
"profit": d.profit,
"comment": d.comment,
"magic": d.magic,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"query": query,
"matched": deal_list.len(),
"deals": deal_list,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_search_deals_by_magic(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let magic = args.get("magic")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("magic is required"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
let (deals, _) = load_report_data(report_dir)?;
let mut filtered: Vec<&Deal> = deals
.iter()
.filter(|d| {
d.entry.to_lowercase().contains("out")
&& d.profit != 0.0
&& d.magic.as_ref().map(|m| m.contains(magic)).unwrap_or(false)
})
.collect();
filtered.sort_by(|a, b| b.time.cmp(&a.time));
filtered.truncate(limit);
let deal_list: Vec<Value> = filtered
.iter()
.map(|d| json!({
"time": d.time,
"deal": d.deal,
"symbol": d.symbol,
"deal_type": d.deal_type,
"volume": d.volume,
"profit": d.profit,
"comment": d.comment,
"magic": d.magic,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"magic": magic,
"matched": deal_list.len(),
"deals": deal_list,
}).to_string() }],
"isError": false
}))
}
// === New Analytics Handlers ===
pub async fn handle_analyze_profit_distribution(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.profit_distribution(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"profit_distribution": result,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_analyze_time_performance(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.time_performance(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"time_performance": result,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_analyze_hold_time_distribution(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.hold_time_analysis(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"hold_time_analysis": result,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_analyze_layer_performance(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.layer_performance(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"layer_performance": result,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_analyze_volume_vs_profit(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.volume_analysis(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"volume_analysis": result,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_analyze_costs(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, _) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.cost_analysis(&deals);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"cost_analysis": result,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_analyze_efficiency(_config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("report_dir is required"))?;
let (deals, metrics) = load_report_data(report_dir)?;
let analyzer = DealAnalyzer::new();
let result = analyzer.efficiency_analysis(&deals, &metrics);
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": true,
"efficiency_analysis": result,
}).to_string() }],
"isError": false
}))
}
// Import Config for analysis module
use crate::models::Config;
+30 -1
View File
@@ -65,6 +65,17 @@ impl ToolHandler {
"analyze_streaks" => analysis::handle_analyze_streaks(&self.config, args).await,
"analyze_concurrent_peak" => analysis::handle_analyze_concurrent_peak(&self.config, args).await,
"compare_baseline" => analysis::handle_compare_baseline(&self.config, args).await,
// Deal query handlers
"list_deals" => analysis::handle_list_deals(&self.config, args).await,
"search_deals_by_comment" => analysis::handle_search_deals_by_comment(&self.config, args).await,
"search_deals_by_magic" => analysis::handle_search_deals_by_magic(&self.config, args).await,
"analyze_profit_distribution" => analysis::handle_analyze_profit_distribution(&self.config, args).await,
"analyze_time_performance" => analysis::handle_analyze_time_performance(&self.config, args).await,
"analyze_hold_time_distribution" => analysis::handle_analyze_hold_time_distribution(&self.config, args).await,
"analyze_layer_performance" => analysis::handle_analyze_layer_performance(&self.config, args).await,
"analyze_volume_vs_profit" => analysis::handle_analyze_volume_vs_profit(&self.config, args).await,
"analyze_costs" => analysis::handle_analyze_costs(&self.config, args).await,
"analyze_efficiency" => analysis::handle_analyze_efficiency(&self.config, args).await,
// Set file handlers
"read_set_file" => setfiles::handle_read_set_file(args).await,
@@ -87,6 +98,14 @@ impl ToolHandler {
"promote_to_baseline" => reports::handle_promote_to_baseline(&self.config, args).await,
"get_history" => reports::handle_get_history(args).await,
"annotate_history" => reports::handle_annotate_history(args).await,
"get_report_by_id" => reports::handle_get_report_by_id(&self.config, args).await,
"get_reports_summary" => reports::handle_get_reports_summary(args).await,
"get_best_reports" => reports::handle_get_best_reports(args).await,
"search_reports_by_tags" => reports::handle_search_reports_by_tags(args).await,
"search_reports_by_date_range" => reports::handle_search_reports_by_date_range(args).await,
"search_reports_by_notes" => reports::handle_search_reports_by_notes(args).await,
"get_reports_by_set_file" => reports::handle_get_reports_by_set_file(args).await,
"get_comparable_reports" => reports::handle_get_comparable_reports(args).await,
// Utility handlers
"check_symbol_data_status" => utility::handle_check_symbol_data_status(&self.config, args).await,
@@ -97,7 +116,17 @@ impl ToolHandler {
"check_mt5_status" => utility::handle_check_mt5_status(&self.config).await,
"create_set_template" => utility::handle_create_set_template(&self.config, args).await,
"export_report" => utility::handle_export_report(&self.config, args).await,
// Debugging/diagnostics handlers
"diagnose_wine" => utility::handle_diagnose_wine(&self.config, args).await,
"get_mt5_logs" => utility::handle_get_mt5_logs(&self.config, args).await,
"search_mt5_errors" => utility::handle_search_mt5_errors(&self.config, args).await,
"check_mt5_process" => utility::handle_check_mt5_process(&self.config, args).await,
"kill_mt5_process" => utility::handle_kill_mt5_process(&self.config, args).await,
"check_system_resources" => utility::handle_check_system_resources(&self.config, args).await,
"validate_mt5_config" => utility::handle_validate_mt5_config(&self.config, args).await,
"get_wine_prefix_info" => utility::handle_get_wine_prefix_info(&self.config, args).await,
"get_backtest_crash_info" => utility::handle_get_backtest_crash_info(&self.config, args).await,
_ => Ok(json!({
"content": [{ "type": "text", "text": format!("Tool '{}' not implemented", name) }],
"isError": true
+415
View File
@@ -433,3 +433,418 @@ pub async fn handle_annotate_history(args: &Value) -> Result<Value> {
"isError": false
}))
}
pub async fn handle_get_report_by_id(_config: &Config, args: &Value) -> Result<Value> {
let id = args.get("id")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("id is required"))?;
let include_chart = args.get("include_chart").and_then(|v| v.as_bool()).unwrap_or(true);
let db = ReportDb::new(&Config::db_path());
db.init()?;
match db.get_by_id(id)? {
Some(entry) => {
let mut response = json!({
"success": true,
"report": {
"id": entry.id,
"expert": entry.expert,
"symbol": entry.symbol,
"timeframe": entry.timeframe,
"from_date": entry.from_date,
"to_date": entry.to_date,
"created_at": entry.created_at,
"net_profit": entry.net_profit,
"profit_factor": entry.profit_factor,
"max_dd_pct": entry.max_dd_pct,
"sharpe_ratio": entry.sharpe_ratio,
"total_trades": entry.total_trades,
"win_rate_pct": entry.win_rate_pct,
"recovery_factor": entry.recovery_factor,
"deposit": entry.deposit,
"currency": entry.currency,
"leverage": entry.leverage,
"duration_seconds": entry.duration_seconds,
"set_file_original": entry.set_file_original,
"set_snapshot_path": entry.set_snapshot_path,
"report_dir": entry.report_dir,
"charts_dir": entry.charts_dir,
"tags": entry.tags,
"notes": entry.notes,
"verdict": entry.verdict,
}
});
if include_chart {
if let Some(charts_dir) = &entry.charts_dir {
let chart_path = Path::new(charts_dir).join("equity.png");
if chart_path.exists() {
match fs::read(&chart_path) {
Ok(bytes) => {
let base64 = BASE64.encode(&bytes);
response["report"]["equity_chart_base64"] = json!(base64);
response["report"]["equity_chart_format"] = json!("png");
}
Err(e) => {
response["report"]["equity_chart_error"] = json!(format!("Failed to read chart: {}", e));
}
}
} else {
response["report"]["equity_chart_error"] = json!("equity.png not found in charts_dir");
}
}
}
Ok(json!({
"content": [{ "type": "text", "text": response.to_string() }],
"isError": false
}))
}
None => {
Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": false,
"error": format!("Report with id '{}' not found", id)
}).to_string() }],
"isError": true
}))
}
}
}
pub async fn handle_get_reports_summary(args: &Value) -> Result<Value> {
let db = ReportDb::new(&Config::db_path());
db.init()?;
let filters = ReportFilters {
expert: args.get("expert").and_then(|v| v.as_str()).map(|s| s.to_string()),
symbol: args.get("symbol").and_then(|v| v.as_str()).map(|s| s.to_string()),
timeframe: args.get("timeframe").and_then(|v| v.as_str()).map(|s| s.to_string()),
verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()),
..Default::default()
};
let stats = db.get_stats(&filters)?;
Ok(json!({
"content": [{ "type": "text", "text": json!({
"total_count": stats.total_count,
"profitable_count": stats.profitable_count,
"pass_verdict_count": stats.pass_verdict_count,
"fail_verdict_count": stats.fail_verdict_count,
"marginal_verdict_count": stats.marginal_verdict_count,
"avg_net_profit": stats.avg_net_profit,
"avg_profit_factor": stats.avg_profit_factor,
"avg_max_dd_pct": stats.avg_max_dd_pct,
"avg_win_rate_pct": stats.avg_win_rate_pct,
"avg_sharpe_ratio": stats.avg_sharpe_ratio,
"profitable_rate": if stats.total_count > 0 {
(stats.profitable_count as f64 / stats.total_count as f64 * 100.0).round()
} else { 0.0 },
"pass_rate": if stats.total_count > 0 {
(stats.pass_verdict_count as f64 / stats.total_count as f64 * 100.0).round()
} else { 0.0 },
}).to_string() }],
"isError": false
}))
}
pub async fn handle_get_best_reports(args: &Value) -> Result<Value> {
let db = ReportDb::new(&Config::db_path());
db.init()?;
let sort_by = args.get("sort_by").and_then(|v| v.as_str()).unwrap_or("profit_factor");
let order = args.get("order").and_then(|v| v.as_str()).unwrap_or("desc");
let ascending = order == "asc";
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize;
let filters = ReportFilters {
expert: args.get("expert").and_then(|v| v.as_str()).map(|s| s.to_string()),
symbol: args.get("symbol").and_then(|v| v.as_str()).map(|s| s.to_string()),
timeframe: args.get("timeframe").and_then(|v| v.as_str()).map(|s| s.to_string()),
verdict: args.get("verdict").and_then(|v| v.as_str()).map(|s| s.to_string()),
..Default::default()
};
let entries = db.get_sorted_by(sort_by, ascending, limit, &filters)?;
let reports: Vec<Value> = entries
.iter()
.map(|e| json!({
"id": e.id,
"expert": e.expert,
"symbol": e.symbol,
"timeframe": e.timeframe,
"from_date": e.from_date,
"to_date": e.to_date,
"created_at": e.created_at,
"net_profit": e.net_profit,
"profit_factor": e.profit_factor,
"max_dd_pct": e.max_dd_pct,
"sharpe_ratio": e.sharpe_ratio,
"total_trades": e.total_trades,
"win_rate_pct": e.win_rate_pct,
"set_file": e.set_file_original,
"verdict": e.verdict,
"tags": e.tags,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"sort_by": sort_by,
"order": order,
"matched": reports.len(),
"reports": reports,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_search_reports_by_tags(args: &Value) -> Result<Value> {
let tags: Vec<String> = args
.get("tags")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
.ok_or_else(|| anyhow::anyhow!("tags array is required"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let db = ReportDb::new(&Config::db_path());
db.init()?;
let entries = db.search_by_tags(&tags, limit)?;
let reports: Vec<Value> = entries
.iter()
.map(|e| json!({
"id": e.id,
"expert": e.expert,
"symbol": e.symbol,
"timeframe": e.timeframe,
"from_date": e.from_date,
"to_date": e.to_date,
"created_at": e.created_at,
"net_profit": e.net_profit,
"profit_factor": e.profit_factor,
"max_dd_pct": e.max_dd_pct,
"total_trades": e.total_trades,
"win_rate_pct": e.win_rate_pct,
"set_file": e.set_file_original,
"verdict": e.verdict,
"tags": e.tags,
"notes": e.notes,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"tags": tags,
"matched": reports.len(),
"reports": reports,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_search_reports_by_date_range(args: &Value) -> Result<Value> {
let from_start = args.get("from_start").and_then(|v| v.as_str());
let from_end = args.get("from_end").and_then(|v| v.as_str());
let to_start = args.get("to_start").and_then(|v| v.as_str());
let to_end = args.get("to_end").and_then(|v| v.as_str());
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let db = ReportDb::new(&Config::db_path());
db.init()?;
let entries = db.search_by_date_range(from_start, from_end, to_start, to_end, limit)?;
let reports: Vec<Value> = entries
.iter()
.map(|e| json!({
"id": e.id,
"expert": e.expert,
"symbol": e.symbol,
"timeframe": e.timeframe,
"from_date": e.from_date,
"to_date": e.to_date,
"created_at": e.created_at,
"net_profit": e.net_profit,
"profit_factor": e.profit_factor,
"max_dd_pct": e.max_dd_pct,
"total_trades": e.total_trades,
"win_rate_pct": e.win_rate_pct,
"set_file": e.set_file_original,
"verdict": e.verdict,
"tags": e.tags,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"from_start": from_start,
"from_end": from_end,
"to_start": to_start,
"to_end": to_end,
"matched": reports.len(),
"reports": reports,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_search_reports_by_notes(args: &Value) -> Result<Value> {
let query = args.get("query")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("query is required"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let db = ReportDb::new(&Config::db_path());
db.init()?;
let entries = db.search_by_notes(query, limit)?;
let reports: Vec<Value> = entries
.iter()
.map(|e| json!({
"id": e.id,
"expert": e.expert,
"symbol": e.symbol,
"timeframe": e.timeframe,
"from_date": e.from_date,
"to_date": e.to_date,
"created_at": e.created_at,
"net_profit": e.net_profit,
"profit_factor": e.profit_factor,
"max_dd_pct": e.max_dd_pct,
"total_trades": e.total_trades,
"win_rate_pct": e.win_rate_pct,
"set_file": e.set_file_original,
"verdict": e.verdict,
"notes": e.notes,
"tags": e.tags,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"query": query,
"matched": reports.len(),
"reports": reports,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_get_reports_by_set_file(args: &Value) -> Result<Value> {
let set_file = args.get("set_file")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("set_file is required"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let db = ReportDb::new(&Config::db_path());
db.init()?;
let entries = db.search_by_set_file(set_file, limit)?;
let reports: Vec<Value> = entries
.iter()
.map(|e| json!({
"id": e.id,
"expert": e.expert,
"symbol": e.symbol,
"timeframe": e.timeframe,
"from_date": e.from_date,
"to_date": e.to_date,
"created_at": e.created_at,
"net_profit": e.net_profit,
"profit_factor": e.profit_factor,
"max_dd_pct": e.max_dd_pct,
"total_trades": e.total_trades,
"win_rate_pct": e.win_rate_pct,
"set_file_original": e.set_file_original,
"set_snapshot_path": e.set_snapshot_path,
"verdict": e.verdict,
"tags": e.tags,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"set_file": set_file,
"matched": reports.len(),
"reports": reports,
}).to_string() }],
"isError": false
}))
}
pub async fn handle_get_comparable_reports(args: &Value) -> Result<Value> {
let db = ReportDb::new(&Config::db_path());
db.init()?;
// Get expert/symbol/timeframe either from report_id or direct args
let (expert, symbol, timeframe, exclude_id) = if let Some(id) = args.get("report_id").and_then(|v| v.as_str()) {
match db.get_by_id(id)? {
Some(entry) => {
let exclude = args.get("exclude_id").and_then(|v| v.as_str()).map(|s| s.to_string());
(entry.expert, entry.symbol, entry.timeframe, exclude.unwrap_or_else(|| id.to_string()))
}
None => return Ok(json!({
"content": [{ "type": "text", "text": json!({
"success": false,
"error": format!("Report with id '{}' not found", id)
}).to_string() }],
"isError": true
}))
}
} else {
let expert = args.get("expert").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("expert or report_id is required"))?;
let symbol = args.get("symbol").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("symbol or report_id is required"))?;
let timeframe = args.get("timeframe").and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("timeframe or report_id is required"))?;
let exclude_id = args.get("exclude_id").and_then(|v| v.as_str()).map(|s| s.to_string());
(expert.to_string(), symbol.to_string(), timeframe.to_string(), exclude_id.unwrap_or_default())
};
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let exclude_opt = if exclude_id.is_empty() { None } else { Some(exclude_id.as_str()) };
let entries = db.get_comparable(&expert, &symbol, &timeframe, exclude_opt, limit)?;
let reports: Vec<Value> = entries
.iter()
.map(|e| json!({
"id": e.id,
"expert": e.expert,
"symbol": e.symbol,
"timeframe": e.timeframe,
"from_date": e.from_date,
"to_date": e.to_date,
"created_at": e.created_at,
"net_profit": e.net_profit,
"profit_factor": e.profit_factor,
"max_dd_pct": e.max_dd_pct,
"total_trades": e.total_trades,
"win_rate_pct": e.win_rate_pct,
"set_file": e.set_file_original,
"verdict": e.verdict,
"tags": e.tags,
}))
.collect();
Ok(json!({
"content": [{ "type": "text", "text": json!({
"expert": expert,
"symbol": symbol,
"timeframe": timeframe,
"exclude_id": exclude_id,
"matched": reports.len(),
"reports": reports,
}).to_string() }],
"isError": false
}))
}
+875
View File
@@ -841,3 +841,878 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result<Valu
"isError": false
}))
}
// === Wine & MT5 Debugging Tools ===
/// Diagnose Wine installation and prefix health
pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Value> {
let mut diagnostics = json!({
"wine_executable": null,
"wine_version": null,
"wine_prefix": null,
"prefix_health": null,
"prefix_exists": false,
"prefix_size_mb": 0,
"errors": Vec::<String>::new(),
"warnings": Vec::<String>::new(),
});
// Check wine executable
if let Some(wine_exe) = config.wine_executable.as_ref() {
diagnostics["wine_executable"] = json!(wine_exe);
// Get Wine version
let version_output = std::process::Command::new(wine_exe)
.arg("--version")
.output();
match version_output {
Ok(output) if output.status.success() => {
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
diagnostics["wine_version"] = json!(version);
}
_ => {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Failed to get Wine version - Wine may not be properly installed")
);
}
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Wine executable not configured")
);
}
// Check Wine prefix
if let Some(mt5_dir) = config.mt5_dir() {
let wine_prefix = mt5_dir
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent());
if let Some(prefix) = wine_prefix {
diagnostics["wine_prefix"] = json!(prefix.to_string_lossy().to_string());
// Check if prefix exists
let prefix_exists = prefix.exists();
diagnostics["prefix_exists"] = json!(prefix_exists);
if prefix_exists {
// Calculate prefix size
let mut total_size = 0u64;
fn calculate_size(dir: &Path, total: &mut u64) {
if let Ok(entries) = fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Ok(meta) = entry.metadata() {
*total += meta.len();
}
} else if path.is_dir() {
calculate_size(&path, total);
}
}
}
}
calculate_size(prefix, &mut total_size);
diagnostics["prefix_size_mb"] = json!((total_size / 1024 / 1024) as i64);
// Check critical directories
let system32 = prefix.join("drive_c/windows/system32");
let program_files = prefix.join("drive_c/Program Files");
if !system32.exists() {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Wine prefix missing system32 directory - prefix may be corrupted")
);
diagnostics["prefix_health"] = json!("corrupted");
} else if !program_files.exists() {
diagnostics["warnings"].as_array_mut().unwrap().push(
json!("Program Files directory not found")
);
diagnostics["prefix_health"] = json!("incomplete");
} else {
diagnostics["prefix_health"] = json!("healthy");
}
// Check for recent Wine errors
let wine_log = prefix.join("wine.log");
if wine_log.exists() {
if let Ok(content) = fs::read_to_string(&wine_log) {
let recent_errors: Vec<&str> = content.lines()
.filter(|l| l.contains("err:") || l.contains("fixme:"))
.rev()
.take(10)
.collect();
if !recent_errors.is_empty() {
diagnostics["recent_wine_errors"] = json!(recent_errors);
}
}
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Wine prefix directory does not exist")
);
diagnostics["prefix_health"] = json!("missing");
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("Could not determine Wine prefix from MT5 directory")
);
}
} else {
diagnostics["errors"].as_array_mut().unwrap().push(
json!("MT5 directory not configured")
);
}
let has_errors = !diagnostics["errors"].as_array().unwrap().is_empty();
Ok(json!({
"content": [{ "type": "text", "text": diagnostics.to_string() }],
"isError": has_errors
}))
}
/// Get MT5 terminal logs
pub async fn handle_get_mt5_logs(config: &Config, args: &Value) -> Result<Value> {
let log_type = args.get("log_type").and_then(|v| v.as_str()).unwrap_or("terminal");
let lines = args.get("lines").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
let search = args.get("search").and_then(|v| v.as_str());
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let log_path = match log_type {
"terminal" => mt5_dir.join("logs").join(format!("{}", chrono::Local::now().format("%Y%m%d"))),
"tester" => mt5_dir.join("Tester").join("logs"),
"metaeditor" => mt5_dir.join("MetaEditor").join("logs"),
_ => mt5_dir.join("logs"),
};
let mut result = json!({
"log_type": log_type,
"log_path": log_path.to_string_lossy().to_string(),
"found": false,
"lines_total": 0,
"lines_returned": 0,
"content": Vec::<String>::new(),
});
// Find log files
let mut log_files: Vec<_> = Vec::new();
if log_path.exists() {
if let Ok(entries) = fs::read_dir(&log_path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "log" {
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
log_files.push((path, modified));
}
}
}
}
}
}
}
}
// Sort by modification time (newest first)
log_files.sort_by(|a, b| b.1.cmp(&a.1));
if let Some((latest_log, _)) = log_files.first() {
result["found"] = json!(true);
if let Ok(content) = fs::read_to_string(latest_log) {
let all_lines: Vec<&str> = content.lines().collect();
result["lines_total"] = json!(all_lines.len());
// Filter and limit lines
let mut filtered: Vec<&str> = all_lines.clone();
// Apply search filter
if let Some(search_term) = search {
let search_lower = search_term.to_lowercase();
filtered.retain(|line| line.to_lowercase().contains(&search_lower));
}
// Get last N lines
let start = filtered.len().saturating_sub(lines);
let final_lines: Vec<String> = filtered[start..].iter().map(|s| s.to_string()).collect();
result["lines_returned"] = json!(final_lines.len());
result["content"] = json!(final_lines);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Search MT5 logs for error patterns
pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result<Value> {
let error_patterns = vec![
"error", "failed", "crash", "exception", "access violation",
"out of memory", "cannot", "unable to", "terminated"
];
let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(24);
let max_errors = args.get("max_errors").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let mut errors_found = Vec::new();
let logs_dir = mt5_dir.join("logs");
let cutoff_time = std::time::SystemTime::now() - std::time::Duration::from_secs(hours_back * 3600);
// Search recent log files
if logs_dir.exists() {
if let Ok(entries) = fs::read_dir(&logs_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "log").unwrap_or(false) {
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
if modified >= cutoff_time {
if let Ok(content) = fs::read_to_string(&path) {
for (i, line) in content.lines().enumerate() {
let line_lower = line.to_lowercase();
for pattern in &error_patterns {
if line_lower.contains(pattern) {
errors_found.push(json!({
"file": path.file_name().unwrap_or_default().to_string_lossy().to_string(),
"line": i + 1,
"content": line.trim().to_string(),
"pattern": pattern,
}));
if errors_found.len() >= max_errors {
break;
}
}
}
}
}
}
}
}
}
if errors_found.len() >= max_errors {
break;
}
}
}
}
let result = json!({
"hours_searched": hours_back,
"errors_found": errors_found.len(),
"max_errors": max_errors,
"errors": errors_found,
"suggestion": if errors_found.is_empty() {
"No errors found in recent logs. Check get_mt5_logs for full log content."
} else {
"Found potential errors. Review the 'content' field for details."
},
});
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Check MT5 process status
pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result<Value> {
use std::process::Command;
let mut result = json!({
"is_running": false,
"processes": Vec::<serde_json::Value>::new(),
"wine_server_running": false,
"total_instances": 0,
});
#[cfg(target_os = "macos")]
{
// Check for MT5 processes
let ps_output = Command::new("ps")
.args(["aux"])
.output();
if let Ok(output) = ps_output {
let content = String::from_utf8_lossy(&output.stdout);
let mut processes = Vec::new();
let mut mt5_count = 0;
let mut wine_server = false;
for line in content.lines() {
let line_lower = line.to_lowercase();
if line_lower.contains("terminal64") || line_lower.contains("metatrader") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 11 {
processes.push(json!({
"pid": parts[1],
"cpu": parts[2],
"mem": parts[3],
"command": parts[10..].join(" "),
}));
mt5_count += 1;
}
}
if line_lower.contains("wineserver") {
wine_server = true;
}
}
result["processes"] = json!(processes);
result["is_running"] = json!(mt5_count > 0);
result["total_instances"] = json!(mt5_count);
result["wine_server_running"] = json!(wine_server);
}
}
#[cfg(target_os = "linux")]
{
let ps_output = Command::new("ps")
.args(["aux"])
.output();
if let Ok(output) = ps_output {
let content = String::from_utf8_lossy(&output.stdout);
let mut processes = Vec::new();
let mut mt5_count = 0;
let mut wine_server = false;
for line in content.lines() {
let line_lower = line.to_lowercase();
if line_lower.contains("terminal64") || line_lower.contains("metatrader") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 11 {
processes.push(json!({
"pid": parts[1],
"cpu": parts[2],
"mem": parts[3],
"command": parts[10..].join(" "),
}));
mt5_count += 1;
}
}
if line_lower.contains("wineserver") {
wine_server = true;
}
}
result["processes"] = json!(processes);
result["is_running"] = json!(mt5_count > 0);
result["total_instances"] = json!(mt5_count);
result["wine_server_running"] = json!(wine_server);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Kill stuck MT5 process
pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<Value> {
use std::process::Command;
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
let pid = args.get("pid").and_then(|v| v.as_str());
let mut killed = Vec::new();
let mut failed = Vec::new();
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
// Get list of MT5 processes
let ps_output = Command::new("ps")
.args(["aux"])
.output();
if let Ok(output) = ps_output {
let content = String::from_utf8_lossy(&output.stdout);
for line in content.lines() {
let line_lower = line.to_lowercase();
let should_kill = if let Some(target_pid) = pid {
line.contains(target_pid) && (line_lower.contains("terminal64") || line_lower.contains("metatrader"))
} else {
line_lower.contains("terminal64") || line_lower.contains("metatrader")
};
if should_kill {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
let process_pid = parts[1];
let signal = if force { "-9" } else { "-15" };
match Command::new("kill").args([signal, process_pid]).output() {
Ok(_) => killed.push(process_pid.to_string()),
Err(e) => failed.push(format!("{}: {}", process_pid, e)),
}
}
}
}
}
// Also kill wineserver if force=true
if force {
let _ = Command::new("killall").arg("wineserver").output();
}
}
let message = if killed.is_empty() {
"No MT5 processes found to kill".to_string()
} else {
format!("Killed {} MT5 process(es)", killed.len())
};
let result = json!({
"killed": killed,
"failed": failed,
"force": force,
"message": message,
});
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": !failed.is_empty()
}))
}
/// Check system resources for MT5
pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> Result<Value> {
use std::process::Command;
let mut result = json!({
"disk_space": null,
"memory": null,
"cpu_cores": 0,
"recommendations": Vec::<String>::new(),
});
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
// Check disk space
let df_output = Command::new("df")
.args(["-h", "/"])
.output();
if let Ok(output) = df_output {
let content = String::from_utf8_lossy(&output.stdout);
for line in content.lines().skip(1) {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 5 {
result["disk_space"] = json!({
"filesystem": parts[0],
"size": parts[1],
"used": parts[2],
"available": parts[3],
"use_percent": parts[4],
});
// Check if low on space
let use_pct = parts[4].trim_end_matches('%').parse::<u32>().unwrap_or(0);
if use_pct > 90 {
result["recommendations"].as_array_mut().unwrap().push(
json!("Disk space critically low. Clean MT5 cache with clean_cache.")
);
} else if use_pct > 80 {
result["recommendations"].as_array_mut().unwrap().push(
json!("Disk space getting low. Consider cleaning cache.")
);
}
}
}
}
// Check memory
#[cfg(target_os = "macos")]
{
let vm_output = Command::new("vm_stat").output();
if let Ok(output) = vm_output {
let content = String::from_utf8_lossy(&output.stdout);
// Parse vm_stat output
let mut free_pages = 0u64;
let mut active_pages = 0u64;
let mut inactive_pages = 0u64;
for line in content.lines() {
if line.contains("Pages free:") {
free_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
} else if line.contains("Pages active:") {
active_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
} else if line.contains("Pages inactive:") {
inactive_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
}
}
let page_size = 4096u64;
let total_mb = ((free_pages + active_pages + inactive_pages) * page_size) / 1024 / 1024;
let free_mb = (free_pages * page_size) / 1024 / 1024;
result["memory"] = json!({
"total_mb": total_mb,
"free_mb": free_mb,
"unit": "MB",
});
if free_mb < 2048 {
result["recommendations"].as_array_mut().unwrap().push(
json!("Low memory available. MT5 may crash during large optimizations.")
);
}
}
}
#[cfg(target_os = "linux")]
{
let mem_output = Command::new("free").args(["-m"]).output();
if let Ok(output) = mem_output {
let content = String::from_utf8_lossy(&output.stdout);
for line in content.lines() {
if line.starts_with("Mem:") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 4 {
result["memory"] = json!({
"total_mb": parts[1].parse::<u64>().unwrap_or(0),
"used_mb": parts[2].parse::<u64>().unwrap_or(0),
"free_mb": parts[3].parse::<u64>().unwrap_or(0),
"unit": "MB",
});
}
}
}
}
}
// Get CPU cores
let nproc_output = Command::new("sysctl")
.args(["-n", "hw.ncpu"])
.output();
if let Ok(output) = nproc_output {
let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0);
result["cpu_cores"] = json!(cores);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Validate MT5 configuration files
pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Result<Value> {
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let mut result = json!({
"terminal_ini": null,
"tester_ini": null,
"config_files_found": Vec::<String>::new(),
"errors": Vec::<String>::new(),
"warnings": Vec::<String>::new(),
});
// Check terminal.ini
let terminal_ini = mt5_dir.join("terminal.ini");
if terminal_ini.exists() {
result["config_files_found"].as_array_mut().unwrap().push(json!("terminal.ini"));
if let Ok(content) = fs::read_to_string(&terminal_ini) {
// Check for common issues
if !content.contains("[Common]") {
result["errors"].as_array_mut().unwrap().push(
json!("terminal.ini missing [Common] section")
);
}
// Extract key settings
let mut settings = serde_json::Map::new();
for line in content.lines() {
if line.starts_with("Login=") {
settings.insert("login".to_string(), json!(line.trim_start_matches("Login=")));
} else if line.starts_with("Server=") {
settings.insert("server".to_string(), json!(line.trim_start_matches("Server=")));
} else if line.starts_with("Expert=") {
settings.insert("expert".to_string(), json!(line.trim_start_matches("Expert=")));
}
}
result["terminal_ini"] = json!(settings);
} else {
result["errors"].as_array_mut().unwrap().push(
json!("Could not read terminal.ini")
);
}
} else {
result["warnings"].as_array_mut().unwrap().push(
json!("terminal.ini not found")
);
}
// Check for tester config
let tester_dir = mt5_dir.join("Tester");
if tester_dir.exists() {
if let Ok(entries) = fs::read_dir(&tester_dir) {
let ini_files: Vec<String> = entries
.flatten()
.filter_map(|e| {
let p = e.path();
if p.extension()?.to_str()? == "ini" {
Some(p.file_name()?.to_string_lossy().to_string())
} else {
None
}
})
.collect();
if !ini_files.is_empty() {
result["tester_ini"] = json!(ini_files);
}
}
}
// Check for common problems
let experts_dir = mt5_dir.join("MQL5").join("Experts");
if !experts_dir.exists() {
result["errors"].as_array_mut().unwrap().push(
json!("MQL5/Experts directory not found - MT5 installation may be incomplete")
);
}
let has_errors = !result["errors"].as_array().unwrap().is_empty();
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": has_errors
}))
}
/// Get Wine prefix detailed information
pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Result<Value> {
let mt5_dir = config.mt5_dir()
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
let wine_prefix = mt5_dir
.parent()
.and_then(|p| p.parent())
.and_then(|p| p.parent());
let mut result = json!({
"prefix_path": null,
"exists": false,
"windows_version": null,
"dll_overrides": Vec::<String>::new(),
"installed_programs": Vec::<String>::new(),
"registry_files": Vec::<String>::new(),
"drive_c_size_mb": 0,
});
if let Some(prefix) = wine_prefix {
result["prefix_path"] = json!(prefix.to_string_lossy().to_string());
result["exists"] = json!(prefix.exists());
if prefix.exists() {
// Check Windows version
let system_reg = prefix.join("system.reg");
if system_reg.exists() {
if let Ok(content) = fs::read_to_string(&system_reg) {
for line in content.lines().take(50) {
if line.contains("\"ProductName\"") {
let parts: Vec<&str> = line.split('"').collect();
if parts.len() >= 4 {
result["windows_version"] = json!(parts[3]);
}
}
}
}
}
// Calculate drive_c size
let drive_c = prefix.join("drive_c");
if drive_c.exists() {
let mut size = 0u64;
fn calc_size(dir: &Path, total: &mut u64) {
if let Ok(entries) = fs::read_dir(dir) {
for e in entries.flatten() {
let p = e.path();
if p.is_file() {
if let Ok(m) = e.metadata() {
*total += m.len();
}
} else if p.is_dir() {
calc_size(&p, total);
}
}
}
}
calc_size(&drive_c, &mut size);
result["drive_c_size_mb"] = json!((size / 1024 / 1024) as i64);
}
// Check for installed programs
let prog_files = prefix.join("drive_c").join("Program Files");
if prog_files.exists() {
if let Ok(entries) = fs::read_dir(&prog_files) {
let programs: Vec<String> = entries
.flatten()
.filter_map(|e| {
let p = e.path();
if p.is_dir() {
Some(p.file_name()?.to_string_lossy().to_string())
} else {
None
}
})
.collect();
result["installed_programs"] = json!(programs);
}
}
// List registry files
let reg_files: Vec<String> = vec![
"system.reg", "user.reg", "userdef.reg"
]
.into_iter()
.filter(|f| prefix.join(f).exists())
.map(|f| f.to_string())
.collect();
result["registry_files"] = json!(reg_files);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}
/// Get backtest crash/failure information
pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Result<Value> {
let report_dir = args.get("report_dir").and_then(|v| v.as_str());
let check_recent = args.get("check_recent").and_then(|v| v.as_bool()).unwrap_or(true);
let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(6);
let mut result = json!({
"crashes_found": Vec::<serde_json::Value>::new(),
"recent_failures": 0,
"common_patterns": Vec::<String>::new(),
});
// Check specific report directory if provided
if let Some(dir) = report_dir {
let path = Path::new(dir);
if path.exists() {
// Check for incomplete markers
let incomplete_marker = path.join(".incomplete");
let error_log = path.join("error.log");
if incomplete_marker.exists() {
result["crashes_found"].as_array_mut().unwrap().push(json!({
"report_dir": dir,
"type": "incomplete",
"reason": "Backtest was interrupted or timed out",
}));
}
if error_log.exists() {
if let Ok(content) = fs::read_to_string(&error_log) {
result["crashes_found"].as_array_mut().unwrap().push(json!({
"report_dir": dir,
"type": "error_log",
"content": content.lines().take(20).collect::<Vec<_>>().join("\n"),
}));
}
}
// Check if deals.csv is missing or empty
let deals_csv = path.join("deals.csv");
if !deals_csv.exists() {
result["crashes_found"].as_array_mut().unwrap().push(json!({
"report_dir": dir,
"type": "missing_deals",
"reason": "deals.csv not found - backtest likely failed",
}));
} else if let Ok(meta) = deals_csv.metadata() {
if meta.len() < 100 {
result["crashes_found"].as_array_mut().unwrap().push(json!({
"report_dir": dir,
"type": "empty_deals",
"reason": "deals.csv is nearly empty - no trades were made",
}));
}
}
}
}
// Check recent reports if requested
if check_recent {
let reports_dir_str = config.get("reports_dir");
let reports_dir = Path::new(&reports_dir_str);
if reports_dir.exists() {
let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(hours_back * 3600);
if let Ok(entries) = fs::read_dir(&reports_dir) {
let mut failures = 0;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Ok(meta) = entry.metadata() {
if let Ok(modified) = meta.modified() {
if modified >= cutoff {
// Check for failure indicators
let has_deals = path.join("deals.csv").exists();
let has_incomplete = path.join(".incomplete").exists();
if !has_deals || has_incomplete {
failures += 1;
}
}
}
}
}
}
result["recent_failures"] = json!(failures);
}
}
}
// Analyze common patterns
let crashes = result["crashes_found"].as_array().unwrap();
if !crashes.is_empty() {
let types: Vec<String> = crashes.iter()
.filter_map(|c| c.get("type").and_then(|v| v.as_str()).map(|s| s.to_string()))
.collect();
if types.contains(&"missing_deals".to_string()) {
result["common_patterns"].as_array_mut().unwrap().push(
json!("Missing deals.csv suggests MT5 crashed during backtest")
);
}
if types.contains(&"incomplete".to_string()) {
result["common_patterns"].as_array_mut().unwrap().push(
json!("Incomplete markers indicate interruptions - check system resources")
);
}
}
Ok(json!({
"content": [{ "type": "text", "text": result.to_string() }],
"isError": false
}))
}