Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1434914e9 | |||
| 6ce8808948 | |||
| 896aa6111e | |||
| aab34a1fde | |||
| 96c7e0a9c2 | |||
| 0c82501e18 |
@@ -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
@@ -481,7 +481,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mt5-quant"
|
||||
version = "1.29.0"
|
||||
version = "1.30.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -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>"]
|
||||
|
||||
@@ -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.*
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -374,4 +374,543 @@ impl ReportDb {
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Get a specific report by ID
|
||||
pub fn get_by_id(&self, id: &str) -> Result<Option<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE id = ?"
|
||||
)?;
|
||||
|
||||
let entry = stmt
|
||||
.query_map([id], |row| {
|
||||
let tags_json: String = row.get(23)?;
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
id: row.get(0)?,
|
||||
expert: row.get(1)?,
|
||||
symbol: row.get(2)?,
|
||||
timeframe: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
from_date: row.get(5)?,
|
||||
to_date: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
set_file_original: row.get(8)?,
|
||||
set_snapshot_path: row.get(9)?,
|
||||
report_dir: row.get(10)?,
|
||||
charts_dir: row.get(11)?,
|
||||
net_profit: row.get(12)?,
|
||||
profit_factor: row.get(13)?,
|
||||
max_dd_pct: row.get(14)?,
|
||||
sharpe_ratio: row.get(15)?,
|
||||
total_trades: row.get(16)?,
|
||||
win_rate_pct: row.get(17)?,
|
||||
recovery_factor: row.get(18)?,
|
||||
deposit: row.get(19)?,
|
||||
currency: row.get(20)?,
|
||||
leverage: row.get(21)?,
|
||||
duration_seconds: row.get(22)?,
|
||||
tags,
|
||||
notes: row.get(24)?,
|
||||
verdict: row.get(25)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.next();
|
||||
|
||||
Ok(entry)
|
||||
}
|
||||
|
||||
/// Search reports by tags (at least one tag must match)
|
||||
pub fn search_by_tags(&self, tags: &[String], limit: usize) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE 1=1"
|
||||
.to_string();
|
||||
|
||||
// Build OR conditions for tags - use JSON1 extension for tag matching
|
||||
if !tags.is_empty() {
|
||||
let tag_conditions: Vec<String> = tags.iter()
|
||||
.map(|tag| format!("tags LIKE '%{}%'", tag.replace("'", "''")))
|
||||
.collect();
|
||||
sql.push_str(&format!(" AND ({})", tag_conditions.join(" OR ")));
|
||||
}
|
||||
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
id: row.get(0)?,
|
||||
expert: row.get(1)?,
|
||||
symbol: row.get(2)?,
|
||||
timeframe: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
from_date: row.get(5)?,
|
||||
to_date: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
set_file_original: row.get(8)?,
|
||||
set_snapshot_path: row.get(9)?,
|
||||
report_dir: row.get(10)?,
|
||||
charts_dir: row.get(11)?,
|
||||
net_profit: row.get(12)?,
|
||||
profit_factor: row.get(13)?,
|
||||
max_dd_pct: row.get(14)?,
|
||||
sharpe_ratio: row.get(15)?,
|
||||
total_trades: row.get(16)?,
|
||||
win_rate_pct: row.get(17)?,
|
||||
recovery_factor: row.get(18)?,
|
||||
deposit: row.get(19)?,
|
||||
currency: row.get(20)?,
|
||||
leverage: row.get(21)?,
|
||||
duration_seconds: row.get(22)?,
|
||||
tags,
|
||||
notes: row.get(24)?,
|
||||
verdict: row.get(25)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Search reports by notes text (case-insensitive LIKE)
|
||||
pub fn search_by_notes(&self, query: &str, limit: usize) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let pattern = format!("%{}%", query.replace("'", "''"));
|
||||
let mut stmt = conn.prepare(
|
||||
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE notes LIKE '{}' ORDER BY created_at DESC LIMIT {}",
|
||||
pattern, limit)
|
||||
)?;
|
||||
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
id: row.get(0)?,
|
||||
expert: row.get(1)?,
|
||||
symbol: row.get(2)?,
|
||||
timeframe: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
from_date: row.get(5)?,
|
||||
to_date: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
set_file_original: row.get(8)?,
|
||||
set_snapshot_path: row.get(9)?,
|
||||
report_dir: row.get(10)?,
|
||||
charts_dir: row.get(11)?,
|
||||
net_profit: row.get(12)?,
|
||||
profit_factor: row.get(13)?,
|
||||
max_dd_pct: row.get(14)?,
|
||||
sharpe_ratio: row.get(15)?,
|
||||
total_trades: row.get(16)?,
|
||||
win_rate_pct: row.get(17)?,
|
||||
recovery_factor: row.get(18)?,
|
||||
deposit: row.get(19)?,
|
||||
currency: row.get(20)?,
|
||||
leverage: row.get(21)?,
|
||||
duration_seconds: row.get(22)?,
|
||||
tags,
|
||||
notes: row.get(24)?,
|
||||
verdict: row.get(25)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Find reports by set file (original or snapshot)
|
||||
pub fn search_by_set_file(&self, set_file: &str, limit: usize) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let pattern = format!("%{}%", set_file.replace("'", "''"));
|
||||
let mut stmt = conn.prepare(
|
||||
&format!("SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE (set_file_original LIKE '{}' OR set_snapshot_path LIKE '{}') \
|
||||
ORDER BY created_at DESC LIMIT {}",
|
||||
pattern, pattern, limit)
|
||||
)?;
|
||||
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map([], |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
id: row.get(0)?,
|
||||
expert: row.get(1)?,
|
||||
symbol: row.get(2)?,
|
||||
timeframe: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
from_date: row.get(5)?,
|
||||
to_date: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
set_file_original: row.get(8)?,
|
||||
set_snapshot_path: row.get(9)?,
|
||||
report_dir: row.get(10)?,
|
||||
charts_dir: row.get(11)?,
|
||||
net_profit: row.get(12)?,
|
||||
profit_factor: row.get(13)?,
|
||||
max_dd_pct: row.get(14)?,
|
||||
sharpe_ratio: row.get(15)?,
|
||||
total_trades: row.get(16)?,
|
||||
win_rate_pct: row.get(17)?,
|
||||
recovery_factor: row.get(18)?,
|
||||
deposit: row.get(19)?,
|
||||
currency: row.get(20)?,
|
||||
leverage: row.get(21)?,
|
||||
duration_seconds: row.get(22)?,
|
||||
tags,
|
||||
notes: row.get(24)?,
|
||||
verdict: row.get(25)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Search by backtest date range (from_date and to_date fields)
|
||||
pub fn search_by_date_range(
|
||||
&self,
|
||||
from_start: Option<&str>,
|
||||
from_end: Option<&str>,
|
||||
to_start: Option<&str>,
|
||||
to_end: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE 1=1"
|
||||
.to_string();
|
||||
|
||||
let mut params: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(start) = from_start {
|
||||
sql.push_str(" AND from_date >= ?");
|
||||
params.push(start.to_string());
|
||||
}
|
||||
if let Some(end) = from_end {
|
||||
sql.push_str(" AND from_date <= ?");
|
||||
params.push(end.to_string());
|
||||
}
|
||||
if let Some(start) = to_start {
|
||||
sql.push_str(" AND to_date >= ?");
|
||||
params.push(start.to_string());
|
||||
}
|
||||
if let Some(end) = to_end {
|
||||
sql.push_str(" AND to_date <= ?");
|
||||
params.push(end.to_string());
|
||||
}
|
||||
|
||||
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
id: row.get(0)?,
|
||||
expert: row.get(1)?,
|
||||
symbol: row.get(2)?,
|
||||
timeframe: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
from_date: row.get(5)?,
|
||||
to_date: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
set_file_original: row.get(8)?,
|
||||
set_snapshot_path: row.get(9)?,
|
||||
report_dir: row.get(10)?,
|
||||
charts_dir: row.get(11)?,
|
||||
net_profit: row.get(12)?,
|
||||
profit_factor: row.get(13)?,
|
||||
max_dd_pct: row.get(14)?,
|
||||
sharpe_ratio: row.get(15)?,
|
||||
total_trades: row.get(16)?,
|
||||
win_rate_pct: row.get(17)?,
|
||||
recovery_factor: row.get(18)?,
|
||||
deposit: row.get(19)?,
|
||||
currency: row.get(20)?,
|
||||
leverage: row.get(21)?,
|
||||
duration_seconds: row.get(22)?,
|
||||
tags,
|
||||
notes: row.get(24)?,
|
||||
verdict: row.get(25)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Get comparable reports (same expert/symbol/timeframe for comparison)
|
||||
pub fn get_comparable(
|
||||
&self,
|
||||
expert: &str,
|
||||
symbol: &str,
|
||||
timeframe: &str,
|
||||
exclude_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE expert = ? AND symbol = ? AND timeframe = ?"
|
||||
.to_string();
|
||||
|
||||
let mut params: Vec<String> = vec![
|
||||
expert.to_string(),
|
||||
symbol.to_string(),
|
||||
timeframe.to_string(),
|
||||
];
|
||||
|
||||
if let Some(id) = exclude_id {
|
||||
sql.push_str(" AND id != ?");
|
||||
params.push(id.to_string());
|
||||
}
|
||||
|
||||
sql.push_str(&format!(" ORDER BY created_at DESC LIMIT {}", limit));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map(rusqlite::params_from_iter(params.iter()), |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
id: row.get(0)?,
|
||||
expert: row.get(1)?,
|
||||
symbol: row.get(2)?,
|
||||
timeframe: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
from_date: row.get(5)?,
|
||||
to_date: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
set_file_original: row.get(8)?,
|
||||
set_snapshot_path: row.get(9)?,
|
||||
report_dir: row.get(10)?,
|
||||
charts_dir: row.get(11)?,
|
||||
net_profit: row.get(12)?,
|
||||
profit_factor: row.get(13)?,
|
||||
max_dd_pct: row.get(14)?,
|
||||
sharpe_ratio: row.get(15)?,
|
||||
total_trades: row.get(16)?,
|
||||
win_rate_pct: row.get(17)?,
|
||||
recovery_factor: row.get(18)?,
|
||||
deposit: row.get(19)?,
|
||||
currency: row.get(20)?,
|
||||
leverage: row.get(21)?,
|
||||
duration_seconds: row.get(22)?,
|
||||
tags,
|
||||
notes: row.get(24)?,
|
||||
verdict: row.get(25)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Get reports sorted by a specific metric (for best/worst queries)
|
||||
pub fn get_sorted_by(
|
||||
&self,
|
||||
sort_column: &str,
|
||||
ascending: bool,
|
||||
limit: usize,
|
||||
filters: &ReportFilters,
|
||||
) -> Result<Vec<ReportEntry>> {
|
||||
let conn = self.connect()?;
|
||||
|
||||
// Validate sort column to prevent SQL injection
|
||||
let valid_columns = ["net_profit", "profit_factor", "max_dd_pct", "win_rate_pct",
|
||||
"sharpe_ratio", "recovery_factor", "total_trades", "created_at"];
|
||||
if !valid_columns.contains(&sort_column) {
|
||||
return Err(anyhow::anyhow!("Invalid sort column: {}", sort_column));
|
||||
}
|
||||
|
||||
let mut sql = "SELECT id, expert, symbol, timeframe, model, from_date, to_date, \
|
||||
created_at, set_file_original, set_snapshot_path, report_dir, charts_dir, \
|
||||
net_profit, profit_factor, max_dd_pct, sharpe_ratio, total_trades, \
|
||||
win_rate_pct, recovery_factor, deposit, currency, leverage, \
|
||||
duration_seconds, tags, notes, verdict \
|
||||
FROM reports WHERE 1=1"
|
||||
.to_string();
|
||||
|
||||
let mut filter_params: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(ea) = &filters.expert {
|
||||
sql.push_str(" AND expert LIKE ?");
|
||||
filter_params.push(format!("%{}%", ea));
|
||||
}
|
||||
if let Some(sym) = &filters.symbol {
|
||||
sql.push_str(" AND symbol = ?");
|
||||
filter_params.push(sym.clone());
|
||||
}
|
||||
if let Some(tf) = &filters.timeframe {
|
||||
sql.push_str(" AND timeframe = ?");
|
||||
filter_params.push(tf.clone());
|
||||
}
|
||||
if let Some(verdict) = &filters.verdict {
|
||||
sql.push_str(" AND verdict = ?");
|
||||
filter_params.push(verdict.clone());
|
||||
}
|
||||
|
||||
// For non-created_at columns, only include rows where that column is not NULL
|
||||
if sort_column != "created_at" {
|
||||
sql.push_str(&format!(" AND {} IS NOT NULL", sort_column));
|
||||
}
|
||||
|
||||
let order = if ascending { "ASC" } else { "DESC" };
|
||||
sql.push_str(&format!(" ORDER BY {} {} LIMIT {}", sort_column, order, limit));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let entries: Vec<ReportEntry> = stmt
|
||||
.query_map(rusqlite::params_from_iter(filter_params.iter()), |row| {
|
||||
let tags_str: String = row.get(23).unwrap_or_else(|_| "[]".to_string());
|
||||
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
|
||||
Ok(ReportEntry {
|
||||
id: row.get(0)?,
|
||||
expert: row.get(1)?,
|
||||
symbol: row.get(2)?,
|
||||
timeframe: row.get(3)?,
|
||||
model: row.get(4)?,
|
||||
from_date: row.get(5)?,
|
||||
to_date: row.get(6)?,
|
||||
created_at: row.get(7)?,
|
||||
set_file_original: row.get(8)?,
|
||||
set_snapshot_path: row.get(9)?,
|
||||
report_dir: row.get(10)?,
|
||||
charts_dir: row.get(11)?,
|
||||
net_profit: row.get(12)?,
|
||||
profit_factor: row.get(13)?,
|
||||
max_dd_pct: row.get(14)?,
|
||||
sharpe_ratio: row.get(15)?,
|
||||
total_trades: row.get(16)?,
|
||||
win_rate_pct: row.get(17)?,
|
||||
recovery_factor: row.get(18)?,
|
||||
deposit: row.get(19)?,
|
||||
currency: row.get(20)?,
|
||||
leverage: row.get(21)?,
|
||||
duration_seconds: row.get(22)?,
|
||||
tags,
|
||||
notes: row.get(24)?,
|
||||
verdict: row.get(25)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Get aggregate statistics for reports
|
||||
pub fn get_stats(&self, filters: &ReportFilters) -> Result<ReportStats> {
|
||||
let conn = self.connect()?;
|
||||
|
||||
let mut sql = "SELECT \
|
||||
COUNT(*), \
|
||||
AVG(net_profit), \
|
||||
AVG(profit_factor), \
|
||||
AVG(max_dd_pct), \
|
||||
AVG(win_rate_pct), \
|
||||
AVG(sharpe_ratio), \
|
||||
SUM(CASE WHEN net_profit > 0 THEN 1 ELSE 0 END), \
|
||||
SUM(CASE WHEN verdict = 'pass' THEN 1 ELSE 0 END), \
|
||||
SUM(CASE WHEN verdict = 'fail' THEN 1 ELSE 0 END), \
|
||||
SUM(CASE WHEN verdict = 'marginal' THEN 1 ELSE 0 END) \
|
||||
FROM reports WHERE 1=1"
|
||||
.to_string();
|
||||
|
||||
let mut filter_params: Vec<String> = Vec::new();
|
||||
|
||||
if let Some(ea) = &filters.expert {
|
||||
sql.push_str(" AND expert LIKE ?");
|
||||
filter_params.push(format!("%{}%", ea));
|
||||
}
|
||||
if let Some(sym) = &filters.symbol {
|
||||
sql.push_str(" AND symbol = ?");
|
||||
filter_params.push(sym.clone());
|
||||
}
|
||||
if let Some(tf) = &filters.timeframe {
|
||||
sql.push_str(" AND timeframe = ?");
|
||||
filter_params.push(tf.clone());
|
||||
}
|
||||
if let Some(verdict) = &filters.verdict {
|
||||
sql.push_str(" AND verdict = ?");
|
||||
filter_params.push(verdict.clone());
|
||||
}
|
||||
|
||||
let stats: ReportStats = conn.query_row(
|
||||
&sql,
|
||||
rusqlite::params_from_iter(filter_params.iter()),
|
||||
|row| {
|
||||
let total: i64 = row.get(0)?;
|
||||
let profitable: Option<i64> = row.get(6)?;
|
||||
let pass_count: Option<i64> = row.get(7)?;
|
||||
let fail_count: Option<i64> = row.get(8)?;
|
||||
let marginal_count: Option<i64> = row.get(9)?;
|
||||
|
||||
Ok(ReportStats {
|
||||
total_count: total as usize,
|
||||
avg_net_profit: row.get(1)?,
|
||||
avg_profit_factor: row.get(2)?,
|
||||
avg_max_dd_pct: row.get(3)?,
|
||||
avg_win_rate_pct: row.get(4)?,
|
||||
avg_sharpe_ratio: row.get(5)?,
|
||||
profitable_count: profitable.unwrap_or(0) as usize,
|
||||
pass_verdict_count: pass_count.unwrap_or(0) as usize,
|
||||
fail_verdict_count: fail_count.unwrap_or(0) as usize,
|
||||
marginal_verdict_count: marginal_count.unwrap_or(0) as usize,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(stats)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct ReportStats {
|
||||
pub total_count: usize,
|
||||
pub avg_net_profit: Option<f64>,
|
||||
pub avg_profit_factor: Option<f64>,
|
||||
pub avg_max_dd_pct: Option<f64>,
|
||||
pub avg_win_rate_pct: Option<f64>,
|
||||
pub avg_sharpe_ratio: Option<f64>,
|
||||
pub profitable_count: usize,
|
||||
pub pass_verdict_count: usize,
|
||||
pub fail_verdict_count: usize,
|
||||
pub marginal_verdict_count: usize,
|
||||
}
|
||||
|
||||
@@ -126,3 +126,155 @@ pub fn tool_analyze_concurrent_peak() -> Value {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_list_deals() -> Value {
|
||||
json!({
|
||||
"name": "list_deals",
|
||||
"description": "List individual deals from a backtest report with optional filters",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string", "description": "Path to report directory containing deals.csv" },
|
||||
"deal_type": { "type": "string", "enum": ["buy", "sell"], "description": "Filter by deal type" },
|
||||
"min_profit": { "type": "number", "description": "Minimum profit (use negative for losses)" },
|
||||
"max_profit": { "type": "number", "description": "Maximum profit" },
|
||||
"start_date": { "type": "string", "description": "Start date filter (YYYY.MM.DD)" },
|
||||
"end_date": { "type": "string", "description": "End date filter (YYYY.MM.DD)" },
|
||||
"min_volume": { "type": "number", "description": "Minimum volume/lots" },
|
||||
"max_volume": { "type": "number", "description": "Maximum volume/lots" },
|
||||
"limit": { "type": "integer", "default": 100, "description": "Max deals to return" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_deals_by_comment() -> Value {
|
||||
json!({
|
||||
"name": "search_deals_by_comment",
|
||||
"description": "Search deals by comment text (case-insensitive partial match)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir", "query"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" },
|
||||
"query": { "type": "string", "description": "Search text in comments" },
|
||||
"limit": { "type": "integer", "default": 50 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_search_deals_by_magic() -> Value {
|
||||
json!({
|
||||
"name": "search_deals_by_magic",
|
||||
"description": "Filter deals by magic number (EA identifier)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir", "magic"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" },
|
||||
"magic": { "type": "string", "description": "Magic number to filter by" },
|
||||
"limit": { "type": "integer", "default": 100 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_profit_distribution() -> Value {
|
||||
json!({
|
||||
"name": "analyze_profit_distribution",
|
||||
"description": "Analyze profit distribution - small/medium/large wins and losses with detailed buckets",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_time_performance() -> Value {
|
||||
json!({
|
||||
"name": "analyze_time_performance",
|
||||
"description": "Analyze performance by hour of day and day of week",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_hold_time_distribution() -> Value {
|
||||
json!({
|
||||
"name": "analyze_hold_time_distribution",
|
||||
"description": "Analyze hold time distribution and correlation with profit",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_layer_performance() -> Value {
|
||||
json!({
|
||||
"name": "analyze_layer_performance",
|
||||
"description": "Analyze performance by grid layer (extracted from deal comments)",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_volume_vs_profit() -> Value {
|
||||
json!({
|
||||
"name": "analyze_volume_vs_profit",
|
||||
"description": "Analyze correlation between volume and profit, plus performance by volume bucket",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_costs() -> Value {
|
||||
json!({
|
||||
"name": "analyze_costs",
|
||||
"description": "Analyze commission and swap costs impact on profitability",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_analyze_efficiency() -> Value {
|
||||
json!({
|
||||
"name": "analyze_efficiency",
|
||||
"description": "Calculate efficiency metrics: profit per hour/day, annualized return, trade frequency",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["report_dir"],
|
||||
"properties": {
|
||||
"report_dir": { "type": "string" }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -841,3 +841,878 @@ pub async fn handle_export_report(_config: &Config, args: &Value) -> Result<Valu
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
// === Wine & MT5 Debugging Tools ===
|
||||
|
||||
/// Diagnose Wine installation and prefix health
|
||||
pub async fn handle_diagnose_wine(config: &Config, _args: &Value) -> Result<Value> {
|
||||
let mut diagnostics = json!({
|
||||
"wine_executable": null,
|
||||
"wine_version": null,
|
||||
"wine_prefix": null,
|
||||
"prefix_health": null,
|
||||
"prefix_exists": false,
|
||||
"prefix_size_mb": 0,
|
||||
"errors": Vec::<String>::new(),
|
||||
"warnings": Vec::<String>::new(),
|
||||
});
|
||||
|
||||
// Check wine executable
|
||||
if let Some(wine_exe) = config.wine_executable.as_ref() {
|
||||
diagnostics["wine_executable"] = json!(wine_exe);
|
||||
|
||||
// Get Wine version
|
||||
let version_output = std::process::Command::new(wine_exe)
|
||||
.arg("--version")
|
||||
.output();
|
||||
|
||||
match version_output {
|
||||
Ok(output) if output.status.success() => {
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
diagnostics["wine_version"] = json!(version);
|
||||
}
|
||||
_ => {
|
||||
diagnostics["errors"].as_array_mut().unwrap().push(
|
||||
json!("Failed to get Wine version - Wine may not be properly installed")
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
diagnostics["errors"].as_array_mut().unwrap().push(
|
||||
json!("Wine executable not configured")
|
||||
);
|
||||
}
|
||||
|
||||
// Check Wine prefix
|
||||
if let Some(mt5_dir) = config.mt5_dir() {
|
||||
let wine_prefix = mt5_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent());
|
||||
|
||||
if let Some(prefix) = wine_prefix {
|
||||
diagnostics["wine_prefix"] = json!(prefix.to_string_lossy().to_string());
|
||||
|
||||
// Check if prefix exists
|
||||
let prefix_exists = prefix.exists();
|
||||
diagnostics["prefix_exists"] = json!(prefix_exists);
|
||||
|
||||
if prefix_exists {
|
||||
// Calculate prefix size
|
||||
let mut total_size = 0u64;
|
||||
fn calculate_size(dir: &Path, total: &mut u64) {
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
*total += meta.len();
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
calculate_size(&path, total);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
calculate_size(prefix, &mut total_size);
|
||||
diagnostics["prefix_size_mb"] = json!((total_size / 1024 / 1024) as i64);
|
||||
|
||||
// Check critical directories
|
||||
let system32 = prefix.join("drive_c/windows/system32");
|
||||
let program_files = prefix.join("drive_c/Program Files");
|
||||
|
||||
if !system32.exists() {
|
||||
diagnostics["errors"].as_array_mut().unwrap().push(
|
||||
json!("Wine prefix missing system32 directory - prefix may be corrupted")
|
||||
);
|
||||
diagnostics["prefix_health"] = json!("corrupted");
|
||||
} else if !program_files.exists() {
|
||||
diagnostics["warnings"].as_array_mut().unwrap().push(
|
||||
json!("Program Files directory not found")
|
||||
);
|
||||
diagnostics["prefix_health"] = json!("incomplete");
|
||||
} else {
|
||||
diagnostics["prefix_health"] = json!("healthy");
|
||||
}
|
||||
|
||||
// Check for recent Wine errors
|
||||
let wine_log = prefix.join("wine.log");
|
||||
if wine_log.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&wine_log) {
|
||||
let recent_errors: Vec<&str> = content.lines()
|
||||
.filter(|l| l.contains("err:") || l.contains("fixme:"))
|
||||
.rev()
|
||||
.take(10)
|
||||
.collect();
|
||||
if !recent_errors.is_empty() {
|
||||
diagnostics["recent_wine_errors"] = json!(recent_errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
diagnostics["errors"].as_array_mut().unwrap().push(
|
||||
json!("Wine prefix directory does not exist")
|
||||
);
|
||||
diagnostics["prefix_health"] = json!("missing");
|
||||
}
|
||||
} else {
|
||||
diagnostics["errors"].as_array_mut().unwrap().push(
|
||||
json!("Could not determine Wine prefix from MT5 directory")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
diagnostics["errors"].as_array_mut().unwrap().push(
|
||||
json!("MT5 directory not configured")
|
||||
);
|
||||
}
|
||||
|
||||
let has_errors = !diagnostics["errors"].as_array().unwrap().is_empty();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": diagnostics.to_string() }],
|
||||
"isError": has_errors
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get MT5 terminal logs
|
||||
pub async fn handle_get_mt5_logs(config: &Config, args: &Value) -> Result<Value> {
|
||||
let log_type = args.get("log_type").and_then(|v| v.as_str()).unwrap_or("terminal");
|
||||
let lines = args.get("lines").and_then(|v| v.as_u64()).unwrap_or(100) as usize;
|
||||
let search = args.get("search").and_then(|v| v.as_str());
|
||||
|
||||
let mt5_dir = config.mt5_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
|
||||
|
||||
let log_path = match log_type {
|
||||
"terminal" => mt5_dir.join("logs").join(format!("{}", chrono::Local::now().format("%Y%m%d"))),
|
||||
"tester" => mt5_dir.join("Tester").join("logs"),
|
||||
"metaeditor" => mt5_dir.join("MetaEditor").join("logs"),
|
||||
_ => mt5_dir.join("logs"),
|
||||
};
|
||||
|
||||
let mut result = json!({
|
||||
"log_type": log_type,
|
||||
"log_path": log_path.to_string_lossy().to_string(),
|
||||
"found": false,
|
||||
"lines_total": 0,
|
||||
"lines_returned": 0,
|
||||
"content": Vec::<String>::new(),
|
||||
});
|
||||
|
||||
// Find log files
|
||||
let mut log_files: Vec<_> = Vec::new();
|
||||
if log_path.exists() {
|
||||
if let Ok(entries) = fs::read_dir(&log_path) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
if let Some(ext) = path.extension() {
|
||||
if ext == "log" {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
log_files.push((path, modified));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by modification time (newest first)
|
||||
log_files.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
|
||||
if let Some((latest_log, _)) = log_files.first() {
|
||||
result["found"] = json!(true);
|
||||
|
||||
if let Ok(content) = fs::read_to_string(latest_log) {
|
||||
let all_lines: Vec<&str> = content.lines().collect();
|
||||
result["lines_total"] = json!(all_lines.len());
|
||||
|
||||
// Filter and limit lines
|
||||
let mut filtered: Vec<&str> = all_lines.clone();
|
||||
|
||||
// Apply search filter
|
||||
if let Some(search_term) = search {
|
||||
let search_lower = search_term.to_lowercase();
|
||||
filtered.retain(|line| line.to_lowercase().contains(&search_lower));
|
||||
}
|
||||
|
||||
// Get last N lines
|
||||
let start = filtered.len().saturating_sub(lines);
|
||||
let final_lines: Vec<String> = filtered[start..].iter().map(|s| s.to_string()).collect();
|
||||
|
||||
result["lines_returned"] = json!(final_lines.len());
|
||||
result["content"] = json!(final_lines);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Search MT5 logs for error patterns
|
||||
pub async fn handle_search_mt5_errors(config: &Config, args: &Value) -> Result<Value> {
|
||||
let error_patterns = vec![
|
||||
"error", "failed", "crash", "exception", "access violation",
|
||||
"out of memory", "cannot", "unable to", "terminated"
|
||||
];
|
||||
let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(24);
|
||||
let max_errors = args.get("max_errors").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
|
||||
|
||||
let mt5_dir = config.mt5_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
|
||||
|
||||
let mut errors_found = Vec::new();
|
||||
let logs_dir = mt5_dir.join("logs");
|
||||
let cutoff_time = std::time::SystemTime::now() - std::time::Duration::from_secs(hours_back * 3600);
|
||||
|
||||
// Search recent log files
|
||||
if logs_dir.exists() {
|
||||
if let Ok(entries) = fs::read_dir(&logs_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|e| e == "log").unwrap_or(false) {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if modified >= cutoff_time {
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
let line_lower = line.to_lowercase();
|
||||
for pattern in &error_patterns {
|
||||
if line_lower.contains(pattern) {
|
||||
errors_found.push(json!({
|
||||
"file": path.file_name().unwrap_or_default().to_string_lossy().to_string(),
|
||||
"line": i + 1,
|
||||
"content": line.trim().to_string(),
|
||||
"pattern": pattern,
|
||||
}));
|
||||
if errors_found.len() >= max_errors {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors_found.len() >= max_errors {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = json!({
|
||||
"hours_searched": hours_back,
|
||||
"errors_found": errors_found.len(),
|
||||
"max_errors": max_errors,
|
||||
"errors": errors_found,
|
||||
"suggestion": if errors_found.is_empty() {
|
||||
"No errors found in recent logs. Check get_mt5_logs for full log content."
|
||||
} else {
|
||||
"Found potential errors. Review the 'content' field for details."
|
||||
},
|
||||
});
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check MT5 process status
|
||||
pub async fn handle_check_mt5_process(_config: &Config, _args: &Value) -> Result<Value> {
|
||||
use std::process::Command;
|
||||
|
||||
let mut result = json!({
|
||||
"is_running": false,
|
||||
"processes": Vec::<serde_json::Value>::new(),
|
||||
"wine_server_running": false,
|
||||
"total_instances": 0,
|
||||
});
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Check for MT5 processes
|
||||
let ps_output = Command::new("ps")
|
||||
.args(["aux"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = ps_output {
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
let mut processes = Vec::new();
|
||||
let mut mt5_count = 0;
|
||||
let mut wine_server = false;
|
||||
|
||||
for line in content.lines() {
|
||||
let line_lower = line.to_lowercase();
|
||||
|
||||
if line_lower.contains("terminal64") || line_lower.contains("metatrader") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 11 {
|
||||
processes.push(json!({
|
||||
"pid": parts[1],
|
||||
"cpu": parts[2],
|
||||
"mem": parts[3],
|
||||
"command": parts[10..].join(" "),
|
||||
}));
|
||||
mt5_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if line_lower.contains("wineserver") {
|
||||
wine_server = true;
|
||||
}
|
||||
}
|
||||
|
||||
result["processes"] = json!(processes);
|
||||
result["is_running"] = json!(mt5_count > 0);
|
||||
result["total_instances"] = json!(mt5_count);
|
||||
result["wine_server_running"] = json!(wine_server);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let ps_output = Command::new("ps")
|
||||
.args(["aux"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = ps_output {
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
let mut processes = Vec::new();
|
||||
let mut mt5_count = 0;
|
||||
let mut wine_server = false;
|
||||
|
||||
for line in content.lines() {
|
||||
let line_lower = line.to_lowercase();
|
||||
|
||||
if line_lower.contains("terminal64") || line_lower.contains("metatrader") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 11 {
|
||||
processes.push(json!({
|
||||
"pid": parts[1],
|
||||
"cpu": parts[2],
|
||||
"mem": parts[3],
|
||||
"command": parts[10..].join(" "),
|
||||
}));
|
||||
mt5_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if line_lower.contains("wineserver") {
|
||||
wine_server = true;
|
||||
}
|
||||
}
|
||||
|
||||
result["processes"] = json!(processes);
|
||||
result["is_running"] = json!(mt5_count > 0);
|
||||
result["total_instances"] = json!(mt5_count);
|
||||
result["wine_server_running"] = json!(wine_server);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Kill stuck MT5 process
|
||||
pub async fn handle_kill_mt5_process(_config: &Config, args: &Value) -> Result<Value> {
|
||||
use std::process::Command;
|
||||
|
||||
let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let pid = args.get("pid").and_then(|v| v.as_str());
|
||||
|
||||
let mut killed = Vec::new();
|
||||
let mut failed = Vec::new();
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
// Get list of MT5 processes
|
||||
let ps_output = Command::new("ps")
|
||||
.args(["aux"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = ps_output {
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
for line in content.lines() {
|
||||
let line_lower = line.to_lowercase();
|
||||
|
||||
let should_kill = if let Some(target_pid) = pid {
|
||||
line.contains(target_pid) && (line_lower.contains("terminal64") || line_lower.contains("metatrader"))
|
||||
} else {
|
||||
line_lower.contains("terminal64") || line_lower.contains("metatrader")
|
||||
};
|
||||
|
||||
if should_kill {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let process_pid = parts[1];
|
||||
let signal = if force { "-9" } else { "-15" };
|
||||
|
||||
match Command::new("kill").args([signal, process_pid]).output() {
|
||||
Ok(_) => killed.push(process_pid.to_string()),
|
||||
Err(e) => failed.push(format!("{}: {}", process_pid, e)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also kill wineserver if force=true
|
||||
if force {
|
||||
let _ = Command::new("killall").arg("wineserver").output();
|
||||
}
|
||||
}
|
||||
|
||||
let message = if killed.is_empty() {
|
||||
"No MT5 processes found to kill".to_string()
|
||||
} else {
|
||||
format!("Killed {} MT5 process(es)", killed.len())
|
||||
};
|
||||
|
||||
let result = json!({
|
||||
"killed": killed,
|
||||
"failed": failed,
|
||||
"force": force,
|
||||
"message": message,
|
||||
});
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": !failed.is_empty()
|
||||
}))
|
||||
}
|
||||
|
||||
/// Check system resources for MT5
|
||||
pub async fn handle_check_system_resources(_config: &Config, _args: &Value) -> Result<Value> {
|
||||
use std::process::Command;
|
||||
|
||||
let mut result = json!({
|
||||
"disk_space": null,
|
||||
"memory": null,
|
||||
"cpu_cores": 0,
|
||||
"recommendations": Vec::<String>::new(),
|
||||
});
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
{
|
||||
// Check disk space
|
||||
let df_output = Command::new("df")
|
||||
.args(["-h", "/"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = df_output {
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
for line in content.lines().skip(1) {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 5 {
|
||||
result["disk_space"] = json!({
|
||||
"filesystem": parts[0],
|
||||
"size": parts[1],
|
||||
"used": parts[2],
|
||||
"available": parts[3],
|
||||
"use_percent": parts[4],
|
||||
});
|
||||
|
||||
// Check if low on space
|
||||
let use_pct = parts[4].trim_end_matches('%').parse::<u32>().unwrap_or(0);
|
||||
if use_pct > 90 {
|
||||
result["recommendations"].as_array_mut().unwrap().push(
|
||||
json!("Disk space critically low. Clean MT5 cache with clean_cache.")
|
||||
);
|
||||
} else if use_pct > 80 {
|
||||
result["recommendations"].as_array_mut().unwrap().push(
|
||||
json!("Disk space getting low. Consider cleaning cache.")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check memory
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let vm_output = Command::new("vm_stat").output();
|
||||
if let Ok(output) = vm_output {
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
// Parse vm_stat output
|
||||
let mut free_pages = 0u64;
|
||||
let mut active_pages = 0u64;
|
||||
let mut inactive_pages = 0u64;
|
||||
|
||||
for line in content.lines() {
|
||||
if line.contains("Pages free:") {
|
||||
free_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
|
||||
} else if line.contains("Pages active:") {
|
||||
active_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
|
||||
} else if line.contains("Pages inactive:") {
|
||||
inactive_pages = line.split_whitespace().nth(2).unwrap_or("0").trim_end_matches('.').parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
let page_size = 4096u64;
|
||||
let total_mb = ((free_pages + active_pages + inactive_pages) * page_size) / 1024 / 1024;
|
||||
let free_mb = (free_pages * page_size) / 1024 / 1024;
|
||||
|
||||
result["memory"] = json!({
|
||||
"total_mb": total_mb,
|
||||
"free_mb": free_mb,
|
||||
"unit": "MB",
|
||||
});
|
||||
|
||||
if free_mb < 2048 {
|
||||
result["recommendations"].as_array_mut().unwrap().push(
|
||||
json!("Low memory available. MT5 may crash during large optimizations.")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mem_output = Command::new("free").args(["-m"]).output();
|
||||
if let Ok(output) = mem_output {
|
||||
let content = String::from_utf8_lossy(&output.stdout);
|
||||
for line in content.lines() {
|
||||
if line.starts_with("Mem:") {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 4 {
|
||||
result["memory"] = json!({
|
||||
"total_mb": parts[1].parse::<u64>().unwrap_or(0),
|
||||
"used_mb": parts[2].parse::<u64>().unwrap_or(0),
|
||||
"free_mb": parts[3].parse::<u64>().unwrap_or(0),
|
||||
"unit": "MB",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get CPU cores
|
||||
let nproc_output = Command::new("sysctl")
|
||||
.args(["-n", "hw.ncpu"])
|
||||
.output();
|
||||
|
||||
if let Ok(output) = nproc_output {
|
||||
let cores = String::from_utf8_lossy(&output.stdout).trim().parse().unwrap_or(0);
|
||||
result["cpu_cores"] = json!(cores);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Validate MT5 configuration files
|
||||
pub async fn handle_validate_mt5_config(config: &Config, _args: &Value) -> Result<Value> {
|
||||
let mt5_dir = config.mt5_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
|
||||
|
||||
let mut result = json!({
|
||||
"terminal_ini": null,
|
||||
"tester_ini": null,
|
||||
"config_files_found": Vec::<String>::new(),
|
||||
"errors": Vec::<String>::new(),
|
||||
"warnings": Vec::<String>::new(),
|
||||
});
|
||||
|
||||
// Check terminal.ini
|
||||
let terminal_ini = mt5_dir.join("terminal.ini");
|
||||
if terminal_ini.exists() {
|
||||
result["config_files_found"].as_array_mut().unwrap().push(json!("terminal.ini"));
|
||||
|
||||
if let Ok(content) = fs::read_to_string(&terminal_ini) {
|
||||
// Check for common issues
|
||||
if !content.contains("[Common]") {
|
||||
result["errors"].as_array_mut().unwrap().push(
|
||||
json!("terminal.ini missing [Common] section")
|
||||
);
|
||||
}
|
||||
|
||||
// Extract key settings
|
||||
let mut settings = serde_json::Map::new();
|
||||
for line in content.lines() {
|
||||
if line.starts_with("Login=") {
|
||||
settings.insert("login".to_string(), json!(line.trim_start_matches("Login=")));
|
||||
} else if line.starts_with("Server=") {
|
||||
settings.insert("server".to_string(), json!(line.trim_start_matches("Server=")));
|
||||
} else if line.starts_with("Expert=") {
|
||||
settings.insert("expert".to_string(), json!(line.trim_start_matches("Expert=")));
|
||||
}
|
||||
}
|
||||
result["terminal_ini"] = json!(settings);
|
||||
} else {
|
||||
result["errors"].as_array_mut().unwrap().push(
|
||||
json!("Could not read terminal.ini")
|
||||
);
|
||||
}
|
||||
} else {
|
||||
result["warnings"].as_array_mut().unwrap().push(
|
||||
json!("terminal.ini not found")
|
||||
);
|
||||
}
|
||||
|
||||
// Check for tester config
|
||||
let tester_dir = mt5_dir.join("Tester");
|
||||
if tester_dir.exists() {
|
||||
if let Ok(entries) = fs::read_dir(&tester_dir) {
|
||||
let ini_files: Vec<String> = entries
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let p = e.path();
|
||||
if p.extension()?.to_str()? == "ini" {
|
||||
Some(p.file_name()?.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !ini_files.is_empty() {
|
||||
result["tester_ini"] = json!(ini_files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for common problems
|
||||
let experts_dir = mt5_dir.join("MQL5").join("Experts");
|
||||
if !experts_dir.exists() {
|
||||
result["errors"].as_array_mut().unwrap().push(
|
||||
json!("MQL5/Experts directory not found - MT5 installation may be incomplete")
|
||||
);
|
||||
}
|
||||
|
||||
let has_errors = !result["errors"].as_array().unwrap().is_empty();
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": has_errors
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get Wine prefix detailed information
|
||||
pub async fn handle_get_wine_prefix_info(config: &Config, _args: &Value) -> Result<Value> {
|
||||
let mt5_dir = config.mt5_dir()
|
||||
.ok_or_else(|| anyhow::anyhow!("MT5 directory not configured"))?;
|
||||
|
||||
let wine_prefix = mt5_dir
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.and_then(|p| p.parent());
|
||||
|
||||
let mut result = json!({
|
||||
"prefix_path": null,
|
||||
"exists": false,
|
||||
"windows_version": null,
|
||||
"dll_overrides": Vec::<String>::new(),
|
||||
"installed_programs": Vec::<String>::new(),
|
||||
"registry_files": Vec::<String>::new(),
|
||||
"drive_c_size_mb": 0,
|
||||
});
|
||||
|
||||
if let Some(prefix) = wine_prefix {
|
||||
result["prefix_path"] = json!(prefix.to_string_lossy().to_string());
|
||||
result["exists"] = json!(prefix.exists());
|
||||
|
||||
if prefix.exists() {
|
||||
// Check Windows version
|
||||
let system_reg = prefix.join("system.reg");
|
||||
if system_reg.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&system_reg) {
|
||||
for line in content.lines().take(50) {
|
||||
if line.contains("\"ProductName\"") {
|
||||
let parts: Vec<&str> = line.split('"').collect();
|
||||
if parts.len() >= 4 {
|
||||
result["windows_version"] = json!(parts[3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate drive_c size
|
||||
let drive_c = prefix.join("drive_c");
|
||||
if drive_c.exists() {
|
||||
let mut size = 0u64;
|
||||
fn calc_size(dir: &Path, total: &mut u64) {
|
||||
if let Ok(entries) = fs::read_dir(dir) {
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_file() {
|
||||
if let Ok(m) = e.metadata() {
|
||||
*total += m.len();
|
||||
}
|
||||
} else if p.is_dir() {
|
||||
calc_size(&p, total);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
calc_size(&drive_c, &mut size);
|
||||
result["drive_c_size_mb"] = json!((size / 1024 / 1024) as i64);
|
||||
}
|
||||
|
||||
// Check for installed programs
|
||||
let prog_files = prefix.join("drive_c").join("Program Files");
|
||||
if prog_files.exists() {
|
||||
if let Ok(entries) = fs::read_dir(&prog_files) {
|
||||
let programs: Vec<String> = entries
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
Some(p.file_name()?.to_string_lossy().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
result["installed_programs"] = json!(programs);
|
||||
}
|
||||
}
|
||||
|
||||
// List registry files
|
||||
let reg_files: Vec<String> = vec![
|
||||
"system.reg", "user.reg", "userdef.reg"
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|f| prefix.join(f).exists())
|
||||
.map(|f| f.to_string())
|
||||
.collect();
|
||||
result["registry_files"] = json!(reg_files);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
/// Get backtest crash/failure information
|
||||
pub async fn handle_get_backtest_crash_info(config: &Config, args: &Value) -> Result<Value> {
|
||||
let report_dir = args.get("report_dir").and_then(|v| v.as_str());
|
||||
let check_recent = args.get("check_recent").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
let hours_back = args.get("hours_back").and_then(|v| v.as_u64()).unwrap_or(6);
|
||||
|
||||
let mut result = json!({
|
||||
"crashes_found": Vec::<serde_json::Value>::new(),
|
||||
"recent_failures": 0,
|
||||
"common_patterns": Vec::<String>::new(),
|
||||
});
|
||||
|
||||
// Check specific report directory if provided
|
||||
if let Some(dir) = report_dir {
|
||||
let path = Path::new(dir);
|
||||
if path.exists() {
|
||||
// Check for incomplete markers
|
||||
let incomplete_marker = path.join(".incomplete");
|
||||
let error_log = path.join("error.log");
|
||||
|
||||
if incomplete_marker.exists() {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "incomplete",
|
||||
"reason": "Backtest was interrupted or timed out",
|
||||
}));
|
||||
}
|
||||
|
||||
if error_log.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&error_log) {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "error_log",
|
||||
"content": content.lines().take(20).collect::<Vec<_>>().join("\n"),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Check if deals.csv is missing or empty
|
||||
let deals_csv = path.join("deals.csv");
|
||||
if !deals_csv.exists() {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "missing_deals",
|
||||
"reason": "deals.csv not found - backtest likely failed",
|
||||
}));
|
||||
} else if let Ok(meta) = deals_csv.metadata() {
|
||||
if meta.len() < 100 {
|
||||
result["crashes_found"].as_array_mut().unwrap().push(json!({
|
||||
"report_dir": dir,
|
||||
"type": "empty_deals",
|
||||
"reason": "deals.csv is nearly empty - no trades were made",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check recent reports if requested
|
||||
if check_recent {
|
||||
let reports_dir_str = config.get("reports_dir");
|
||||
let reports_dir = Path::new(&reports_dir_str);
|
||||
if reports_dir.exists() {
|
||||
let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(hours_back * 3600);
|
||||
|
||||
if let Ok(entries) = fs::read_dir(&reports_dir) {
|
||||
let mut failures = 0;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Ok(meta) = entry.metadata() {
|
||||
if let Ok(modified) = meta.modified() {
|
||||
if modified >= cutoff {
|
||||
// Check for failure indicators
|
||||
let has_deals = path.join("deals.csv").exists();
|
||||
let has_incomplete = path.join(".incomplete").exists();
|
||||
|
||||
if !has_deals || has_incomplete {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result["recent_failures"] = json!(failures);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze common patterns
|
||||
let crashes = result["crashes_found"].as_array().unwrap();
|
||||
if !crashes.is_empty() {
|
||||
let types: Vec<String> = crashes.iter()
|
||||
.filter_map(|c| c.get("type").and_then(|v| v.as_str()).map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
if types.contains(&"missing_deals".to_string()) {
|
||||
result["common_patterns"].as_array_mut().unwrap().push(
|
||||
json!("Missing deals.csv suggests MT5 crashed during backtest")
|
||||
);
|
||||
}
|
||||
if types.contains(&"incomplete".to_string()) {
|
||||
result["common_patterns"].as_array_mut().unwrap().push(
|
||||
json!("Incomplete markers indicate interruptions - check system resources")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"content": [{ "type": "text", "text": result.to_string() }],
|
||||
"isError": false
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user