diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml new file mode 100644 index 0000000..e976c38 --- /dev/null +++ b/.github/workflows/ci-python.yml @@ -0,0 +1,43 @@ +name: Python tests + +# Unit-tests the FastAPI/TCP bridge (server.py) and the MCP wrapper with pytest +# so a change that breaks the API contract, request routing, timeout/abandon +# handling, or validation can't be merged. The MQL5 EA is guarded separately by +# compile-ea.yml. + +on: + push: + paths: + - "server.py" + - "mcp_server.py" + - "tests/**" + - "requirements*.txt" + - ".github/workflows/ci-python.yml" + pull_request: + paths: + - "server.py" + - "mcp_server.py" + - "tests/**" + - "requirements*.txt" + - ".github/workflows/ci-python.yml" + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt -r requirements-dev.txt + + - name: Run tests + run: pytest -q diff --git a/README.md b/README.md index 4ecead7..0ea2a89 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # MT5 Trade Split Manager 🤖 [![Compile EA](https://github.com/codedpro/mt5-trade-split-manager/actions/workflows/compile-ea.yml/badge.svg)](https://github.com/codedpro/mt5-trade-split-manager/actions/workflows/compile-ea.yml) +[![Python tests](https://github.com/codedpro/mt5-trade-split-manager/actions/workflows/ci-python.yml/badge.svg)](https://github.com/codedpro/mt5-trade-split-manager/actions/workflows/ci-python.yml) [![MetaTrader 5](https://img.shields.io/badge/MetaTrader-5-blue.svg)](https://www.metatrader5.com/) [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![FastAPI](https://img.shields.io/badge/FastAPI-0.109.0-green.svg)](https://fastapi.tiangolo.com/) [![AI Agent Friendly](https://img.shields.io/badge/AI%20Agent-Friendly-brightgreen.svg)](https://claude.ai) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -**🤖 AI-Agent Friendly** | **Professional-grade MetaTrader 5 Expert Advisor with intelligent order splitting, automatic trailing stop loss, and REST API integration for Gold (XAUUSD) and Silver (XAGUSD) trading. Built for seamless integration with Claude AI and other AI agents.** +**🤖 AI-Agent Friendly** | **Professional-grade MetaTrader 5 Expert Advisor with intelligent order splitting, automatic trailing stop loss, and REST API integration for any MT5 symbol (Gold/XAUUSD and Silver/XAGUSD included as examples). Ships with a Model Context Protocol (MCP) server for native Claude integration and other AI agents.** ## 🤖 Why AI-Agent Friendly? @@ -29,15 +30,16 @@ System: Splits order → Manages positions → Returns status ## 🚀 Key Features -- ✅ **Automatic Order Splitting** - Splits single order into 5 positions with optimized volume distribution (60%, 10%, 10%, 10%, 10%) +- ✅ **Automatic Order Splitting** - Splits a single order into 1-10 positions with configurable volume distribution (defaults to 60/10/10/10/10 for 5 levels) - ✅ **Smart Trailing Stop Loss** - Automatically moves SL to breakeven when TP2 is reached - ✅ **Dual TP Structures** - Supports both 15-pip and 30-pip initial TP configurations - ✅ **Safe Shutdown Mode** - Protects positions when EA is offline - ✅ **REST API Integration** - FastAPI server for external signal processing +- ✅ **MCP Server** - Native Model Context Protocol server so Claude Code / Claude Desktop can inspect and control MT5 directly - ✅ **TCP Socket Communication** - High-performance bidirectional communication - ✅ **Order Type Safety** - Validates STOP/LIMIT (and SL/TP sides) against the live market and **rejects** wrong-side orders instead of silently reinterpreting your intent - ✅ **Position Recovery** - Rebuilds tracking from existing orders on restart -- ✅ **Multi-Symbol Support** - Optimized for Gold (XAUUSD) and Silver (XAGUSD) +- ✅ **Any-Symbol Support** - Works on any MT5 symbol; volumes snap to the broker's lot step and prices normalize to the symbol's digits (Gold/XAUUSD and Silver/XAGUSD are examples, not limits) - ✅ **Risk Management** - Daily loss limits, max positions, spread checks - ✅ **AI Agent Ready** - Perfect for Claude AI, ChatGPT, and automation workflows @@ -49,6 +51,7 @@ System: Splits order → Manages positions → Returns status - [Trailing Stop Logic](#-trailing-stop-logic) - [Safe Shutdown Mode](#-safe-shutdown-mode) - [API Documentation](#-api-documentation) +- [MCP Server](#-mcp-server) - [Configuration](#-configuration) - [Installation](#-installation) - [Usage Examples](#-usage-examples) @@ -133,11 +136,19 @@ Traditional single-TP orders force you to choose between: - Taking profit early (leaving money on the table) - Holding for larger profit (risking reversal) -**Solution**: Split one order into 5 positions with different TPs! +**Solution**: Split one order into **1 to 10 positions**, each with its own TP! ### Volume Distribution -When you place 0.1 lot, the EA creates 5 separate orders: +You choose how many TP levels to send (1-10). If you don't send a `volume_split`, +the EA/server apply a default weighting: + +- **1 level** → the whole lot goes to that single TP (`[1.0]`). +- **N ≥ 2 levels** → TP1 gets **60%** and the remaining **40%** is shared evenly + across the other `N-1` levels. For **5 levels** this reproduces the original + **60/10/10/10/10** distribution exactly, so existing 5-level clients are unchanged. + +When you place 0.1 lot with the default 5-level split, the EA creates 5 separate orders: | Order | Volume | % of Total | Take Profit | Purpose | |-------|--------|------------|-------------|---------| @@ -147,6 +158,34 @@ When you place 0.1 lot, the EA creates 5 separate orders: | TP4 | 0.01 | 10% | 105 pips | Extended profit | | TP5 | 0.01 | 10% | 135 pips | Maximum profit target | +### Custom Volume Distribution + +Send an optional `volume_split` array (one fraction per TP level) to override the +default. The rules are validated on both the server (422 on failure) and the EA: + +- **Same length** as `tp_levels`. +- **Each entry ≥ 0**, with **at least one entry > 0**. +- **Sum ≈ 1.0** (accepted within `[0.99, 1.01]`). +- **A `0` entry skips that level** — no order is placed for it. This lets you keep + a fixed 5-level TP ladder but only actually fill, say, TP1 and TP3. + +**Example — a 3-level 50/30/20 split:** + +```json +"tp_levels": [4103.0, 4106.0, 4109.0], +"volume_split": [0.5, 0.3, 0.2] +``` + +### Lot-Step Rounding + +Each per-level volume is `lot_size × ratio`, **floored down to the broker's lot +step** (`SYMBOL_VOLUME_STEP`). Any rounding leftover is added back to the level with +the largest ratio so the totals still add up. If a non-zero level would floor to +**below the broker's minimum lot** (`SYMBOL_VOLUME_MIN`), the whole order is +**rejected** with a message naming the offending level and the smallest `lot_size` +that would make it viable — the EA never silently drops a leg. Entry, SL, and every +TP price are normalized to the symbol's digit precision before the order is sent. + ### TP Structure Options **Option 1: Standard Structure (15-pip initial)** @@ -163,11 +202,17 @@ When you place 0.1 lot, the EA creates 5 separate orders: - TP4: 120 pips (+30) - TP5: 150 pips (+30) -### Pip Value Calibration +### Any Symbol, Correct Precision + +Because you send **absolute TP/SL/entry prices** (not pip offsets), the system is +not tied to any particular instrument. The EA reads the symbol's own volume step, +minimum lot, and digit precision from MT5, so the same request format works on FX +pairs, metals, indices, or crypto CFDs — whatever your broker exposes. -The EA automatically adjusts pip values based on symbol: -- **Gold (XAUUSD)**: 1 pip = 0.10 (e.g., 2650.00 → 2651.00) -- **Silver (XAGUSD)**: 1 pip = 0.01 (e.g., 29.50 → 29.51) +The pip figures used throughout these examples are just conveniences for the two +symbols the project was first built around: +- **Gold (XAUUSD)**: 1 pip ≈ 0.10 (e.g., 2650.00 → 2651.00) +- **Silver (XAGUSD)**: 1 pip ≈ 0.01 (e.g., 29.50 → 29.51) ## 🎢 Trailing Stop Logic @@ -317,12 +362,13 @@ Place a new order with automatic splitting. ``` **Parameters:** -- `symbol` (string): Trading symbol ("XAUUSD" or "XAGUSD") +- `symbol` (string): Any MT5 symbol your broker exposes (e.g. "XAUUSD", "XAGUSD", "EURUSD", "BTCUSD") - `order_type` (string): "BUY_STOP", "SELL_STOP", "BUY_LIMIT", or "SELL_LIMIT". The price must be on the correct side of the market for the type (e.g. BUY_STOP above the ask); wrong-side requests are rejected with an explanatory message rather than converted. - `price` (float): Entry price - `sl` (float): Stop loss price -- `tp_levels` (array): 5 take profit levels -- `lot_size` (float): Total volume (will be split into 5 orders) +- `tp_levels` (array): **1 to 10** take-profit prices (must be on the correct side of entry) +- `volume_split` (array, optional): Fractions of `lot_size`, one per TP level. Same length as `tp_levels`, each entry ≥ 0, at least one > 0, and they must sum to ~1.0 (within `[0.99, 1.01]`). A `0` entry skips that level. Omit to use the default 60/40 split (60/10/10/10/10 for 5 levels). +- `lot_size` (float): Total volume, split across the TP levels (floored to the broker's lot step; rejected if a non-zero leg would fall below the broker minimum) - `deviation` (int, optional): Maximum price deviation in pips - `comment` (string, optional): Order comment - `magic_number` (int, optional): Magic number for identification @@ -336,6 +382,27 @@ Place a new order with automatic splitting. } ``` +**Example — custom 3-level split (50/30/20):** + +```bash +curl -X POST http://localhost:8080/order \ + -H "Content-Type: application/json" \ + -d '{ + "symbol": "EURUSD", + "order_type": "BUY_LIMIT", + "price": 1.0850, + "sl": 1.0820, + "tp_levels": [1.0880, 1.0910, 1.0940], + "volume_split": [0.5, 0.3, 0.2], + "lot_size": 0.3 + }' +``` + +This places 0.15 lots @ 1.0880, 0.09 lots @ 1.0910, and 0.06 lots @ 1.0940 +(subject to the broker's lot step). Validation failures — bad length, wrong sum, +negative or all-zero splits, more than 10 levels — return `422` with a message +naming the rule that was broken. + #### 3. Get Positions **GET** `/positions` @@ -456,6 +523,84 @@ Activate safe shutdown mode - consolidate all TPs to TP2 level. } ``` +## 🧩 MCP Server + +The repo ships an optional **Model Context Protocol (MCP)** server (`mcp_server.py`) +that wraps the REST bridge as tools an LLM client can call natively. With it, +Claude Code or Claude Desktop can list your positions, read account stats, and +(optionally) place and manage split orders — no manual `curl` required. + +It talks to the same running `server.py` bridge over HTTP, so start the bridge +first (and point the MCP server at it if it isn't on the default URL). + +### Install + +```bash +pip install -r requirements-mcp.txt +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `MT5_BRIDGE_URL` | `http://127.0.0.1:8080` | Base URL of the running `server.py` bridge | +| `MT5_API_KEY` | *(unset)* | Sent as `X-API-Key` on every request; set it to match the bridge's `API_KEY` | +| `MT5_MCP_ENABLE_TRADING` | *(off)* | Set to `1` or `true` to register the trading tools. **Left off by default so a fresh install cannot move money.** | + +### Tools + +**Read-only (always available):** +- `list_positions` — all open positions (filled legs of split orders) +- `list_pending_orders` — pending stop/limit legs price hasn't reached yet +- `account_stats` — balance, equity, margin, free margin, open profit +- `bridge_health` — check the bridge is up and whether auth is required + +**Trading (only when `MT5_MCP_ENABLE_TRADING=1`):** +- `place_split_order` — mirror of `POST /order`, including `volume_split` +- `close_position` — close an open position by ticket +- `cancel_order` — cancel a pending order by ticket +- `safe_shutdown` — consolidate remaining TP legs to the TP2 level + +Responses are the bridge's JSON passed straight through; connection/HTTP failures +come back as short, readable messages instead of raw tracebacks. + +### Setup — Claude Code + +```bash +claude mcp add mt5 \ + --env MT5_BRIDGE_URL=http://127.0.0.1:8080 \ + --env MT5_MCP_ENABLE_TRADING=1 \ + -- python /path/to/mt5-trade-split-manager/mcp_server.py +``` + +Drop the `MT5_MCP_ENABLE_TRADING` line to keep the connection read-only. Add +`--env MT5_API_KEY=your-secret` if the bridge runs with `API_KEY` set. + +### Setup — Claude Desktop + +Add an entry to your `claude_desktop_config.json` (Settings → Developer → Edit Config): + +```json +{ + "mcpServers": { + "mt5": { + "command": "python", + "args": ["/path/to/mt5-trade-split-manager/mcp_server.py"], + "env": { + "MT5_BRIDGE_URL": "http://127.0.0.1:8080", + "MT5_MCP_ENABLE_TRADING": "1" + } + } + } +} +``` + +Restart Claude Desktop after editing. Omit `MT5_MCP_ENABLE_TRADING` for a +read-only connection, and add `MT5_API_KEY` under `env` if the bridge requires it. + +> ⚠️ Enabling the trading tools lets the model place and close **real** orders. +> Keep it off unless you intend that, and always demo-test first. + ## ⚙️ Configuration ### config.json @@ -579,13 +724,34 @@ curl -X POST http://localhost:8080/order \ }' ``` -### Example 3: Check All Positions +### Example 3: Any Symbol with a Custom 3-Level Split + +```bash +curl -X POST http://localhost:8080/order \ + -H "Content-Type: application/json" \ + -d '{ + "symbol": "EURUSD", + "order_type": "BUY_LIMIT", + "price": 1.0850, + "sl": 1.0820, + "tp_levels": [1.0880, 1.0910, 1.0940], + "volume_split": [0.5, 0.3, 0.2], + "lot_size": 0.3 + }' +``` + +This creates 3 orders (volumes floored to the broker's lot step): +- 0.15 lots @ TP 1.0880 (50%) +- 0.09 lots @ TP 1.0910 (30%) +- 0.06 lots @ TP 1.0940 (20%) + +### Example 4: Check All Positions ```bash curl http://localhost:8080/positions ``` -### Example 4: Activate Safe Shutdown +### Example 5: Activate Safe Shutdown ```bash curl -X POST http://localhost:8080/safe-shutdown @@ -662,13 +828,13 @@ python server.py ## ❓ FAQ **Q: Can I use this with other symbols besides Gold and Silver?** -A: The code is optimized for XAUUSD and XAGUSD pip values. For other symbols, you'll need to adjust the `pipValue` calculation in the MQL5 code. +A: Yes. You send absolute TP/SL/entry prices, and the EA reads each symbol's volume step, minimum lot, and digit precision directly from MT5, so any broker symbol works (FX pairs, metals, indices, crypto CFDs). XAUUSD/XAGUSD are just the examples this project started with. **Q: What happens if I restart MT5 while positions are open?** A: The EA has position recovery logic that rebuilds order group tracking from position comments. However, trailing SL won't trigger during the restart period. **Q: Can I change the volume distribution (60/10/10/10/10)?** -A: Yes, edit the `volumes[]` array in the `ExecuteOrder()` function in bulk-add-signals.mq5. +A: Yes — send a `volume_split` array in the API request (see [Split Order System](#-split-order-system)). No code editing required; the 60/40 default is only applied when you omit it. You can also use 1-10 levels instead of exactly 5. **Q: Does this work on MT4?** A: No, this is specifically designed for MT5. MT4 uses a different API and doesn't support the same socket operations. @@ -683,7 +849,7 @@ A: Currently, the system uses a REST API. You can build a web frontend that call A: The EA uses socket communication which isn't available in Strategy Tester. For backtesting, you'd need to modify the code to use simulated data instead of TCP sockets. **Q: What's the minimum lot size?** -A: Depends on your broker. Since orders are split into 0.01 lot increments, your broker must support 0.01 lots (micro lots). +A: Depends on your broker. Each split leg is floored to the broker's lot step (`SYMBOL_VOLUME_STEP`), and if a non-zero leg would land below the broker minimum (`SYMBOL_VOLUME_MIN`) the whole order is rejected with a message telling you the smallest `lot_size` that would make that level viable. ## 📄 License @@ -711,9 +877,11 @@ Contributions welcome! Please: ## 🎯 Roadmap +- [x] Support for additional symbols (any MT5 symbol, broker-driven lot step & precision) +- [x] Configurable 1-10 level splits with custom `volume_split` +- [x] MCP server for native Claude Code / Claude Desktop integration - [ ] Web-based dashboard for monitoring positions - [ ] Telegram bot integration for alerts -- [ ] Support for additional symbols - [ ] Advanced risk management presets - [ ] Trade journal and analytics - [ ] Multi-account support @@ -728,4 +896,4 @@ Contributions welcome! Please: **Made with ❤️ for algorithmic traders** -**Keywords:** MetaTrader 5, MT5, Expert Advisor, EA, Gold Trading, XAUUSD, Silver Trading, XAGUSD, Forex Bot, Trading Bot, Algorithmic Trading, Automated Trading, Split Orders, Trailing Stop, Risk Management, FastAPI, Python Trading, MQL5, Trading API, REST API, Socket Trading +**Keywords:** MetaTrader 5, MT5, Expert Advisor, EA, Gold Trading, XAUUSD, Silver Trading, XAGUSD, Forex Bot, Trading Bot, Algorithmic Trading, Automated Trading, Split Orders, Trailing Stop, Risk Management, FastAPI, Python Trading, MQL5, Trading API, REST API, Socket Trading, MCP, Model Context Protocol, Claude MCP, AI Trading Agent diff --git a/bulk-add-signals.mq5 b/bulk-add-signals.mq5 index e34ecd2..058ba90 100644 --- a/bulk-add-signals.mq5 +++ b/bulk-add-signals.mq5 @@ -5,13 +5,19 @@ //+------------------------------------------------------------------+ #property copyright "Copyright 2025" #property link "https://www.mql5.com" -#property version "4.00" +#property version "4.20" #property description "Receives trading signals via TCP sockets (MQL5 as client)" #include #include #include +//--- Maximum number of split levels supported per order group. +// Fixed-capacity arrays are used everywhere (no dynamic arrays inside +// structs); every loop respects the group's real count or guards on +// ticket==0 / tp_price<=0 for the unused tail. +#define MAX_SPLITS 10 + //--- Input parameters input string ServerHost = "127.0.0.1"; // Python server host input int ServerPort = 5555; // Python server port @@ -39,7 +45,9 @@ struct TradeCommand { string symbol; double price; double sl; - double tp_levels[5]; + double tp_levels[MAX_SPLITS]; // Requested TP prices (tp_count of them are valid) + double volume_split[MAX_SPLITS]; // Fraction of total lot per level (parallel to tp_levels) + int tp_count; // Number of TP levels actually provided (1..MAX_SPLITS) double lot_size; int deviation; string comment; @@ -47,8 +55,9 @@ struct TradeCommand { struct SplitOrderGroup { string groupId; // Unique identifier for the group (based on entry price + symbol) - ulong tickets[5]; // Tickets for all 5 split orders - double tp_prices[5]; // Actual TP price placed for each split (source of truth) + ulong tickets[MAX_SPLITS]; // Tickets for all split orders (0 = missing/skipped) + double tp_prices[MAX_SPLITS]; // Actual TP price placed for each split (source of truth) + int count; // Number of split levels in this group (1..MAX_SPLITS) bool tp2_reached; // Flag to track if TP2 was reached double entry_price; // Entry price string symbol; // Symbol @@ -108,6 +117,11 @@ void OnDeinit(const int reason) //+------------------------------------------------------------------+ void OnTimer() { + // Run trailing from the timer as well as OnTick: OnTick only fires for + // the chart's own symbol, so groups on other symbols would otherwise not + // trail until this chart happened to tick. + CheckTP2ForTrailingSL(); + // Create TCP socket int socket = SocketCreate(); if(socket == INVALID_HANDLE) @@ -241,6 +255,31 @@ string ProcessCommand(string commandJson) } } +//+------------------------------------------------------------------+ +//| Apply the default volume split when the caller omits one. | +//| N==1 -> [1.0]; N>=2 -> TP1=0.60, each remaining = 0.40/(N-1). | +//| For N==5 this reproduces the legacy 60/10/10/10/10 exactly. | +//+------------------------------------------------------------------+ +void ApplyDefaultVolumeSplit(TradeCommand &cmd) +{ + for(int i = 0; i < MAX_SPLITS; i++) + cmd.volume_split[i] = 0; + + int n = cmd.tp_count; + if(n <= 0) return; + + if(n == 1) + { + cmd.volume_split[0] = 1.0; + return; + } + + cmd.volume_split[0] = 0.60; + double rest = 0.40 / (double)(n - 1); + for(int i = 1; i < n; i++) + cmd.volume_split[i] = rest; +} + //+------------------------------------------------------------------+ //| Handle place order command | //+------------------------------------------------------------------+ @@ -270,7 +309,18 @@ string HandlePlaceOrder(string commandJson) cmd.lot_size = ExtractDouble(dataJson, "lot_size"); cmd.deviation = (int)ExtractDouble(dataJson, "deviation"); - // Extract TP levels + // Zero out the fixed-capacity arrays before parsing. + for(int i = 0; i < MAX_SPLITS; i++) + { + cmd.tp_levels[i] = 0; + cmd.volume_split[i] = 0; + } + cmd.tp_count = 0; + + // Extract TP levels (1..MAX_SPLITS entries). tp_raw is how many the caller + // actually sent; tp_count is that clamped to capacity and drives every + // downstream loop. + int tp_raw = 0; int tp_start = StringFind(dataJson, "\"tp_levels\""); if(tp_start >= 0) { @@ -279,8 +329,10 @@ string HandlePlaceOrder(string commandJson) string tp_str = StringSubstr(dataJson, arr_start + 1, arr_end - arr_start - 1); string tp_values[]; - StringSplit(tp_str, ',', tp_values); - for(int i = 0; i < MathMin(5, ArraySize(tp_values)); i++) + tp_raw = StringSplit(tp_str, ',', tp_values); + if(tp_raw < 0) tp_raw = 0; + int tp_parse = (int)MathMin(MAX_SPLITS, tp_raw); + for(int i = 0; i < tp_parse; i++) { // Clean whitespace string val = tp_values[i]; @@ -288,9 +340,68 @@ string HandlePlaceOrder(string commandJson) StringTrimRight(val); cmd.tp_levels[i] = StringToDouble(val); } + cmd.tp_count = tp_parse; } - Print("Parsed command: ", cmd.order_type, " ", cmd.symbol, " @ ", cmd.price, " SL:", cmd.sl, " Lot:", cmd.lot_size); + // Extract optional volume_split. When present its length must match + // tp_count. When absent, apply the same default formula as the server so + // direct TCP callers behave identically (defense in depth). + bool haveSplit = false; + int vs_raw = 0; + int vs_start = StringFind(dataJson, "\"volume_split\""); + if(vs_start >= 0) + { + int varr_start = StringFind(dataJson, "[", vs_start); + int varr_end = StringFind(dataJson, "]", varr_start); + if(varr_start >= 0 && varr_end > varr_start) + { + string vs_str = StringSubstr(dataJson, varr_start + 1, varr_end - varr_start - 1); + string vs_trim = vs_str; + StringTrimLeft(vs_trim); + StringTrimRight(vs_trim); + if(StringLen(vs_trim) > 0) + { + haveSplit = true; + string vs_values[]; + vs_raw = StringSplit(vs_str, ',', vs_values); + if(vs_raw < 0) vs_raw = 0; + int vs_parse = (int)MathMin(MAX_SPLITS, vs_raw); + for(int i = 0; i < vs_parse; i++) + { + string val = vs_values[i]; + StringTrimLeft(val); + StringTrimRight(val); + cmd.volume_split[i] = StringToDouble(val); + } + } + } + } + + if(!haveSplit) + ApplyDefaultVolumeSplit(cmd); + + Print("Parsed command: ", cmd.order_type, " ", cmd.symbol, " @ ", cmd.price, " SL:", cmd.sl, " Lot:", cmd.lot_size, " TPs:", cmd.tp_count); + + // Validate level count and volume split (mirrors the server-side rules). + if(tp_raw < 1 || tp_raw > MAX_SPLITS) + return BuildResponse(false, StringFormat("tp_levels must contain 1..%d prices (got %d)", MAX_SPLITS, tp_raw), 0); + + if(haveSplit && vs_raw != cmd.tp_count) + return BuildResponse(false, StringFormat("volume_split length (%d) must match tp_levels count (%d)", vs_raw, cmd.tp_count), 0); + + double splitSum = 0; + bool anyPositive = false; + for(int i = 0; i < cmd.tp_count; i++) + { + if(cmd.volume_split[i] < 0) + return BuildResponse(false, StringFormat("volume_split[%d] must be >= 0", i+1), 0); + if(cmd.volume_split[i] > 0) anyPositive = true; + splitSum += cmd.volume_split[i]; + } + if(!anyPositive) + return BuildResponse(false, "volume_split must have at least one entry > 0", 0); + if(splitSum < 0.99 || splitSum > 1.01) + return BuildResponse(false, StringFormat("volume_split must sum to ~1.0 (got %.4f)", splitSum), 0); // Validate if(!ValidateCommand(cmd)) @@ -407,7 +518,7 @@ bool ValidateCommand(TradeCommand &cmd) } //+------------------------------------------------------------------+ -//| Execute order - Now splits into 5 separate orders | +//| Execute order - splits into up to MAX_SPLITS orders per split | //+------------------------------------------------------------------+ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) { @@ -446,7 +557,7 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) if(isBuy && cmd.sl >= cmd.price) { errorMsg = "BUY stop-loss must be below entry price"; Print("[ERR] ", errorMsg); return 0; } if(!isBuy && cmd.sl <= cmd.price) { errorMsg = "SELL stop-loss must be above entry price"; Print("[ERR] ", errorMsg); return 0; } } - for(int t = 0; t < 5; t++) + for(int t = 0; t < cmd.tp_count; t++) { if(cmd.tp_levels[t] <= 0) { errorMsg = StringFormat("TP%d is missing or invalid", t+1); Print("[ERR] ", errorMsg); return 0; } if(isBuy && cmd.tp_levels[t] <= cmd.price) { errorMsg = StringFormat("BUY TP%d must be above entry price", t+1); Print("[ERR] ", errorMsg); return 0; } @@ -455,17 +566,64 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) Print("Executing ", cmd.order_type, " order on ", cmd.symbol); Print("Ask: ", ask, " | Bid: ", bid, " | Order price: ", cmd.price, " | SL: ", cmd.sl, " | Total Lot: ", cmd.lot_size); - Print("Will split into 5 orders: TP1=60%, TP2-TP5=10% each"); - - // Calculate volume splits - // TP1: 60% of total volume - // TP2-TP5: 10% each (remaining 40% split equally) - double volumes[5]; - volumes[0] = NormalizeDouble(cmd.lot_size * 0.60, 2); // 60% for TP1 - volumes[1] = NormalizeDouble(cmd.lot_size * 0.10, 2); // 10% for TP2 - volumes[2] = NormalizeDouble(cmd.lot_size * 0.10, 2); // 10% for TP3 - volumes[3] = NormalizeDouble(cmd.lot_size * 0.10, 2); // 10% for TP4 - volumes[4] = NormalizeDouble(cmd.lot_size * 0.10, 2); // 10% for TP5 + Print("Splitting into ", cmd.tp_count, " order(s) per volume_split"); + + // --- Volume computation (any-symbol correct) --- + // Floor each split to the symbol's volume step; reject the whole order if + // any nonzero split floors below the broker minimum; add the flooring + // leftover to the first level with the largest ratio. + double step = SymbolInfoDouble(cmd.symbol, SYMBOL_VOLUME_STEP); + if(step <= 0) step = 0.01; // Fallback when the broker reports no step + double volMin = SymbolInfoDouble(cmd.symbol, SYMBOL_VOLUME_MIN); + + double volumes[MAX_SPLITS]; + for(int i = 0; i < MAX_SPLITS; i++) volumes[i] = 0; + + double sumVol = 0; + for(int i = 0; i < cmd.tp_count; i++) + { + double v = MathFloor(cmd.lot_size * cmd.volume_split[i] / step + 1e-9) * step; + v = NormalizeDouble(v, 8); // strip floating-point dust from the multiply + + if(cmd.volume_split[i] > 0 && v < volMin) + { + double minViable = MathCeil((volMin / cmd.volume_split[i]) / step - 1e-9) * step; + errorMsg = StringFormat("TP%d volume %.4f below broker minimum %.4f - increase lot_size to at least %.4f", + i+1, v, volMin, minViable); + Print("[ERR] ", errorMsg); + return 0; + } + + volumes[i] = v; + sumVol += v; + } + + // Add the flooring leftover (floored to step) to the first level having the + // largest ratio (deterministic: lowest index wins on ties). + double leftover = MathFloor((cmd.lot_size - sumVol) / step + 1e-9) * step; + if(leftover > 1e-9) + { + int bestIdx = -1; + double bestRatio = -1; + for(int i = 0; i < cmd.tp_count; i++) + { + if(cmd.volume_split[i] > bestRatio) + { + bestRatio = cmd.volume_split[i]; + bestIdx = i; + } + } + if(bestIdx >= 0) + volumes[bestIdx] = NormalizeDouble(volumes[bestIdx] + leftover, 8); + } + + // Normalize entry / SL / every TP to the symbol's digits before OrderOpen. + int digits = (int)SymbolInfoInteger(cmd.symbol, SYMBOL_DIGITS); + double entryN = NormalizeDouble(cmd.price, digits); + double slN = (cmd.sl > 0) ? NormalizeDouble(cmd.sl, digits) : cmd.sl; + double tpN[MAX_SPLITS]; + for(int i = 0; i < MAX_SPLITS; i++) + tpN[i] = (i < cmd.tp_count) ? NormalizeDouble(cmd.tp_levels[i], digits) : 0; // Create a compact, unique group ID. Kept short so it fits inside the MT5 // order comment (brokers truncate long comments, which used to silently @@ -476,20 +634,32 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) // Create split order group int groupIndex = ArraySize(orderGroups); ArrayResize(orderGroups, groupIndex + 1); - orderGroups[groupIndex].groupId = groupId; + orderGroups[groupIndex].groupId = groupId; + orderGroups[groupIndex].count = cmd.tp_count; orderGroups[groupIndex].tp2_reached = false; - orderGroups[groupIndex].entry_price = cmd.price; - orderGroups[groupIndex].symbol = cmd.symbol; - orderGroups[groupIndex].order_type = orderType; - for(int t = 0; t < 5; t++) - orderGroups[groupIndex].tp_prices[t] = cmd.tp_levels[t]; + orderGroups[groupIndex].entry_price = entryN; + orderGroups[groupIndex].symbol = cmd.symbol; + orderGroups[groupIndex].order_type = orderType; + for(int t = 0; t < MAX_SPLITS; t++) + { + orderGroups[groupIndex].tickets[t] = 0; + // Record the normalized TP for placed/failed levels; 0 for skipped + // (volume 0) and the unused tail so drawing/trailing ignore them. + orderGroups[groupIndex].tp_prices[t] = (t < cmd.tp_count && volumes[t] > 0) ? tpN[t] : 0; + } - // Place 5 separate orders + // Place the split orders (skip levels whose split floored to 0 volume) int successCount = 0; ulong firstTicket = 0; - for(int i = 0; i < 5; i++) + for(int i = 0; i < cmd.tp_count; i++) { + if(volumes[i] <= 0) + { + Print("[SKIP] Split level ", i+1, "/", cmd.tp_count, " skipped (volume_split=0)"); + continue; + } + // Functional comment only: "#". No free-text prefix, // so the group/index tokens can't be pushed out by comment truncation. string orderComment = StringFormat("%s#%d", groupId, i+1); @@ -499,9 +669,9 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) orderType, volumes[i], 0, - cmd.price, - cmd.sl, - cmd.tp_levels[i], + entryN, + slN, + tpN[i], ORDER_TIME_GTC, 0, orderComment @@ -514,14 +684,14 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) if(firstTicket == 0) firstTicket = ticket; - Print("[OK] Split order ", i+1, "/5 placed - Ticket: ", ticket, - " | Vol: ", volumes[i], " | TP", i+1, ": ", cmd.tp_levels[i]); + Print("[OK] Split order ", i+1, "/", cmd.tp_count, " placed - Ticket: ", ticket, + " | Vol: ", volumes[i], " | TP", i+1, ": ", tpN[i]); successCount++; } else { errorMsg = trade.ResultRetcodeDescription(); - Print("[ERR] Split order ", i+1, "/5 failed - ", errorMsg); + Print("[ERR] Split order ", i+1, "/", cmd.tp_count, " failed - ", errorMsg); orderGroups[groupIndex].tickets[i] = 0; } } @@ -535,9 +705,10 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) } // Draw TP levels on chart (once for the group) using the ACTUAL TP prices - DrawTPLevels(groupId, cmd.symbol, cmd.price, orderGroups[groupIndex].tp_prices); + // and the ACTUAL per-level volumes. + DrawTPLevels(groupId, cmd.symbol, entryN, orderGroups[groupIndex].tp_prices, volumes, cmd.tp_count); - Print("[OK] Order group placed: ", successCount, "/5 orders successful"); + Print("[OK] Order group placed: ", successCount, "/", cmd.tp_count, " orders successful"); return firstTicket; // Return first ticket as reference } @@ -545,16 +716,16 @@ ulong ExecuteOrder(TradeCommand &cmd, string &errorMsg) //+------------------------------------------------------------------+ //| Draw TP levels on chart | //+------------------------------------------------------------------+ -void DrawTPLevels(string groupId, string symbol, double entryPrice, double &tpPrices[]) +void DrawTPLevels(string groupId, string symbol, double entryPrice, double &tpPrices[], double &volumes[], int count) { - color levelColors[5] = {clrLime, clrGreen, clrYellow, clrOrange, clrRed}; - - string percentages[5] = {"60%", "10%", "10%", "10%", "10%"}; + // 10-entry palette so every possible split level gets a distinct color. + color levelColors[MAX_SPLITS] = {clrLime, clrGreen, clrYellow, clrOrange, clrRed, + clrDeepPink, clrAqua, clrMagenta, clrGold, clrTomato}; - for(int i = 0; i < 5; i++) + for(int i = 0; i < count; i++) { double tpPrice = tpPrices[i]; - if(tpPrice <= 0) continue; // TP already closed / unknown - skip drawing + if(tpPrice <= 0) continue; // TP already closed / skipped / unknown - skip drawing // Create horizontal line string lineName = StringFormat("TP%d_Line_%s", i+1, groupId); @@ -565,10 +736,10 @@ void DrawTPLevels(string groupId, string symbol, double entryPrice, double &tpPr ObjectSetInteger(0, lineName, OBJPROP_BACK, false); ObjectSetInteger(0, lineName, OBJPROP_SELECTABLE, false); - // Create text label + // Create text label (shows the ACTUAL per-level volume) string labelName = StringFormat("TP%d_Label_%s", i+1, groupId); ObjectCreate(0, labelName, OBJ_TEXT, 0, TimeCurrent(), tpPrice); - ObjectSetString(0, labelName, OBJPROP_TEXT, StringFormat(" TP%d: %.3f (%s)", i+1, tpPrice, percentages[i])); + ObjectSetString(0, labelName, OBJPROP_TEXT, StringFormat(" TP%d: %.3f (%.2f lots)", i+1, tpPrice, volumes[i])); ObjectSetInteger(0, labelName, OBJPROP_COLOR, levelColors[i]); ObjectSetInteger(0, labelName, OBJPROP_FONTSIZE, 9); ObjectSetString(0, labelName, OBJPROP_FONT, "Arial Bold"); @@ -624,8 +795,9 @@ void UpdateTPLevelClosed(string groupId, int level) //+------------------------------------------------------------------+ void RemoveTPObjects(string groupId) { - // Remove TP lines and labels - for(int i = 1; i <= 5; i++) + // Remove TP lines and labels. Loop the full capacity so recovered or + // legacy groups (whose real count is unknown here) are fully cleaned. + for(int i = 1; i <= MAX_SPLITS; i++) { ObjectDelete(0, StringFormat("TP%d_Line_%s", i, groupId)); ObjectDelete(0, StringFormat("TP%d_Label_%s", i, groupId)); @@ -645,19 +817,16 @@ void CheckTP2ForTrailingSL() { for(int i = ArraySize(orderGroups) - 1; i >= 0; i--) { - // Skip if TP2 trailing already applied for this group - if(orderGroups[i].tp2_reached) - { - continue; - } - - // Check if TP2 position (ticket index 1) has closed ulong tp2_ticket = orderGroups[i].tickets[1]; - if(tp2_ticket == 0) continue; // Invalid ticket - - // Check if TP2 position no longer exists (was closed by hitting TP) - if(!PositionSelectByTicket(tp2_ticket)) + // Trailing trigger: TP2 must have been placed (ticket > 0), must no + // longer be a pending order (it filled), and must no longer be an + // open position (it closed). Checking positions alone misfired for + // pending groups: a stop order that has not filled yet is NOT a + // position, so TP2 looked "closed" one tick after placement and the + // group's tracking self-destructed before any order ever filled. + if(!orderGroups[i].tp2_reached && tp2_ticket > 0 && + !OrderSelect(tp2_ticket) && !PositionSelectByTicket(tp2_ticket)) { // TP2 was hit! Move SL to TP1 for all remaining positions Print("[HIT] TP2 reached for group ", orderGroups[i].groupId, " - Moving SL to TP1 for all remaining positions"); @@ -668,9 +837,9 @@ void CheckTP2ForTrailingSL() double newSL = orderGroups[i].tp_prices[0]; if(newSL <= 0) newSL = orderGroups[i].entry_price; - // Update SL for all remaining positions (TP3, TP4, TP5) + // Update SL for all remaining positions (TP3..count) int movedCount = 0; - for(int j = 2; j < 5; j++) // Start from index 2 (TP3) + for(int j = 2; j < orderGroups[i].count; j++) // Start from index 2 (TP3) { ulong ticket = orderGroups[i].tickets[j]; if(ticket == 0) continue; @@ -715,21 +884,24 @@ void CheckTP2ForTrailingSL() UpdateTPLevelClosed(orderGroups[i].groupId, 2); } - // Clean up group if all positions closed - bool allClosed = true; - for(int j = 0; j < 5; j++) + // Clean up the group only when NOTHING is alive on any level: neither + // an open position nor a still-pending order. This runs for every + // group (including tp2_reached ones, which previously leaked forever, + // and skipped-TP2 / single-level groups). + bool anyAlive = false; + for(int j = 0; j < orderGroups[i].count; j++) { ulong ticket = orderGroups[i].tickets[j]; - if(ticket > 0 && PositionSelectByTicket(ticket)) + if(ticket > 0 && (OrderSelect(ticket) || PositionSelectByTicket(ticket))) { - allClosed = false; + anyAlive = true; break; } } - if(allClosed) + if(!anyAlive) { - Print("[END] All positions closed for group ", orderGroups[i].groupId); + Print("[END] All orders/positions closed for group ", orderGroups[i].groupId); RemoveTPObjects(orderGroups[i].groupId); ArrayRemove(orderGroups, i, 1); } @@ -782,13 +954,21 @@ bool CheckDailyLossLimit() //+------------------------------------------------------------------+ bool ParseGroupComment(string comment, string &groupId, int &tpLevel) { - // New compact format: "#" + // New compact format: "#". Read ALL consecutive digits after + // '#' so two-digit indices (e.g. "#10") parse correctly. int hashPos = StringFind(comment, "#"); if(hashPos > 0) { groupId = StringSubstr(comment, 0, hashPos); - tpLevel = (int)StringToInteger(StringSubstr(comment, hashPos + 1, 1)); - if(tpLevel >= 1 && tpLevel <= 5) return true; + int p = hashPos + 1; + while(p < StringLen(comment)) + { + ushort ch = StringGetCharacter(comment, p); + if(ch < '0' || ch > '9') break; + p++; + } + tpLevel = (int)StringToInteger(StringSubstr(comment, hashPos + 1, p - (hashPos + 1))); + if(tpLevel >= 1 && tpLevel <= MAX_SPLITS) return true; } // Legacy format: "...|GROUP:|TP:" @@ -799,8 +979,15 @@ bool ParseGroupComment(string comment, string &groupId, int &tpLevel) if(tpPos >= 0) { groupId = StringSubstr(comment, gPos + 7, tpPos - gPos - 7); - tpLevel = (int)StringToInteger(StringSubstr(comment, tpPos + 4, 1)); - if(tpLevel >= 1 && tpLevel <= 5) return true; + int p = tpPos + 4; + while(p < StringLen(comment)) + { + ushort ch = StringGetCharacter(comment, p); + if(ch < '0' || ch > '9') break; + p++; + } + tpLevel = (int)StringToInteger(StringSubstr(comment, tpPos + 4, p - (tpPos + 4))); + if(tpLevel >= 1 && tpLevel <= MAX_SPLITS) return true; } } @@ -818,10 +1005,13 @@ int FindOrCreateGroup(string groupId) int idx = ArraySize(orderGroups); ArrayResize(orderGroups, idx + 1); orderGroups[idx].groupId = groupId; + // Recovered groups don't know their original level count, so use the full + // capacity and rely on ticket==0 / tp_price<=0 guards to skip empty slots. + orderGroups[idx].count = MAX_SPLITS; orderGroups[idx].tp2_reached = false; orderGroups[idx].entry_price = 0; orderGroups[idx].symbol = ""; - for(int t = 0; t < 5; t++) + for(int t = 0; t < MAX_SPLITS; t++) { orderGroups[idx].tickets[t] = 0; orderGroups[idx].tp_prices[t] = 0; @@ -897,11 +1087,12 @@ void RecoverSplitOrders() // Post-process: infer TP2-reached state and redraw visuals. for(int g = 0; g < groupCount; g++) { - // TP2 was already hit if its ticket is gone but a later TP survives. - if(orderGroups[g].tickets[1] == 0 && - (orderGroups[g].tickets[2] > 0 || - orderGroups[g].tickets[3] > 0 || - orderGroups[g].tickets[4] > 0)) + // TP2 was already hit if its ticket is gone but any later TP survives. + bool laterAlive = false; + for(int t = 2; t < MAX_SPLITS; t++) + if(orderGroups[g].tickets[t] > 0) { laterAlive = true; break; } + + if(orderGroups[g].tickets[1] == 0 && laterAlive) { orderGroups[g].tp2_reached = true; Print(" [HIT] TP2 already reached for group ", orderGroups[g].groupId); @@ -909,11 +1100,23 @@ void RecoverSplitOrders() if(orderGroups[g].entry_price > 0) { + // Read back the actual live volume of each surviving split so the + // chart labels are correct after recovery. + double vols[MAX_SPLITS]; + for(int t = 0; t < MAX_SPLITS; t++) + { + vols[t] = 0; + ulong tk = orderGroups[g].tickets[t]; + if(tk == 0) continue; + if(PositionSelectByTicket(tk)) vols[t] = PositionGetDouble(POSITION_VOLUME); + else if(OrderSelect(tk)) vols[t] = OrderGetDouble(ORDER_VOLUME_CURRENT); + } + DrawTPLevels(orderGroups[g].groupId, orderGroups[g].symbol, - orderGroups[g].entry_price, orderGroups[g].tp_prices); + orderGroups[g].entry_price, orderGroups[g].tp_prices, vols, orderGroups[g].count); // Mark already-closed TPs as gray. - for(int t = 0; t < 5; t++) + for(int t = 0; t < orderGroups[g].count; t++) if(orderGroups[g].tickets[t] == 0) UpdateTPLevelClosed(orderGroups[g].groupId, t + 1); @@ -1070,10 +1273,12 @@ string HandleClosePosition(string commandJson) { if(orderGroups[i].groupId == groupId) { - for(int j = 0; j < 5; j++) + for(int j = 0; j < orderGroups[i].count; j++) { + // Alive = still-pending order OR open position; a + // pending sibling must block the group cleanup. ulong checkTicket = orderGroups[i].tickets[j]; - if(checkTicket > 0 && PositionSelectByTicket(checkTicket)) + if(checkTicket > 0 && (OrderSelect(checkTicket) || PositionSelectByTicket(checkTicket))) { allClosed = false; break; @@ -1135,8 +1340,8 @@ string HandleSafeShutdown() int modifiedInGroup = 0; - // Modify TP2, TP3, TP4, TP5 (indices 1-4) - for(int j = 1; j < 5; j++) + // Consolidate levels 2..count (indices 1..count-1) onto TP2's price. + for(int j = 1; j < orderGroups[i].count; j++) { ulong ticket = orderGroups[i].tickets[j]; if(ticket == 0) continue; diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..3581b2d --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,70 @@ +# Roadmap: known gaps and next features + +Last updated: 2026-07-06 (post v4.2.0 expansion). Ordered by (risk x demand). +Each item is small enough for one focused PR. + +## Known bugs / correctness gaps + +1. **`MaxSpreadPips` input is dead code.** Declared in the EA inputs and + documented in the README, but never checked anywhere. Either wire it into + `ValidateCommand` (reject when `(ask-bid)` exceeds the limit, converting + pips per symbol digits) or remove the input and the README row. +2. **`MaxPositions` counts positions, not pending orders.** A burst of split + groups can queue 10 groups x 10 pending orders while `CountOpenPositions()` + returns 0. Count pending orders with the EA's magic number toward the cap + (or add a separate `MaxPendingOrders`). +3. **Recovered groups don't restore `count`** (set to `MAX_SPLITS` with + guards). Harmless today, but `UpdateTPLevelClosed` gray-marks slots that + never existed and the all-alive scan does extra `OrderSelect` calls. + Persisting group state (item 6) fixes this properly. +4. **`partial_close_percent` rides through the whole stack unused.** It's in + the API model, forwarded to the EA, and ignored there. Remove it or + implement partial closes. +5. **Daily-loss day boundary uses server time, not broker midnight.** + `TimeCurrent() % 86400` resets at UTC-day of the server clock; brokers with + offset timezones reset mid-session. Consider `iTime(_Symbol, PERIOD_D1, 0)` + as the boundary. +6. **Group state lives only in RAM + order comments.** If a broker rewrites + comments (some do on partial fills), recovery loses the group. Persist + `orderGroups` to a file (e.g. `MQL5/Files/split_groups.json`) on change and + prefer it during recovery, falling back to comments. + +## Incomplete / promised features + +7. **Trailing trigger/target are hardcoded (TP2 -> TP1).** Natural follow-up + to variable splits: optional `trailing` object in the API + (`{"trigger_level": 2, "sl_to_level": 1}` semantics, breakeven option). + Deliberately out of scope in the v4.2.0 spec. +8. **Safe-shutdown consolidation level is fixed at TP2.** Same + generalization as (7). +9. **Modify-order endpoint.** The API can place, list, close, delete - but + not modify SL/TP of an existing group. AI-agent workflows want + `PATCH /group/{groupId}` (move SL, shift remaining TPs). +10. **Group-level API.** `GET /groups` (tracked split groups with per-level + status) and `DELETE /group/{groupId}` (cancel/close a whole group). Today + callers must correlate tickets themselves from `/positions` + `/orders`. +11. **Telegram/webhook notifications** (README roadmap promise): order + placed/filled, TP hit, trailing applied, safe-shutdown done. +12. **Trade journal** (README roadmap promise): append fills/closures to CSV + or SQLite via the EA's `OnTradeTransaction`, expose `GET /journal`. +13. **Web dashboard** (README roadmap promise): small single-page UI over the + existing REST API - positions, groups, safe-shutdown button. +14. **MCP: group-aware tools.** Once (10) exists, add `list_groups` / + `close_group` tools; also a `dry_run` flag on `place_split_order` that + returns the computed per-level volumes without placing. + +## Infrastructure / quality + +15. **EA integration harness.** The socket protocol is testable without a + broker: a pytest fixture can speak the EA's TCP protocol (fake EA), and a + stub bridge can drive the EA in the Strategy Tester via a compile-time + `#ifdef` transport shim. Would finally cover the EA's JSON parser. +16. **Release automation.** Tag-triggered workflow attaching the compiled + `.ex5` (already built by compile-ea.yml) to a GitHub Release. +17. **CONTRIBUTING.md + issue templates** - the repo is viral; convert stars + into safe PRs (note the ASCII/CRLF rule for the .mq5, CI expectations). +18. **Rotate the exposed PAT** (owner action; noted 2026-07-04). +19. **Modernize pinned Python deps.** requirements.txt pins fastapi 0.109 / + pydantic 2.5.3 / uvicorn 0.27 (early-2024). Newer fastapi drops the + TestClient `app=` kwarg problem (tests currently pin httpx<0.28 to stay + compatible) and picks up security fixes. One PR: bump pins, rerun CI. diff --git a/docs/superpowers/specs/2026-07-04-expansion-design.md b/docs/superpowers/specs/2026-07-04-expansion-design.md new file mode 100644 index 0000000..6b67490 --- /dev/null +++ b/docs/superpowers/specs/2026-07-04-expansion-design.md @@ -0,0 +1,102 @@ +# Expansion design: variable splits, any-symbol, MCP server, tests + CI + +Date: 2026-07-04 +Status: approved (user), pre-implementation +Scope: one release; four components. Compile + unit-test verified; live trading behavior must be demo-tested by the user. + +## 1. API contract (server.py) + +- `tp_levels`: list of 1–10 prices (previously exactly 5). +- `volume_split` (new, optional): list of floats, same length as `tp_levels`. + - Each entry >= 0; at least one entry > 0; sum within [0.99, 1.01]. + - An entry of 0 means "skip this level" (no order placed for it). + - If omitted, the server injects the default: N=1 -> `[1.0]`; N>=2 -> TP1 = 0.60, + each remaining level = `0.40 / (N-1)`. For N=5 this reproduces the legacy + 60/10/10/10/10 exactly, so existing clients are unchanged. +- The command sent to the EA always contains an explicit `volume_split`. +- Validation failures are 422s with messages that state the rule violated. +- All other endpoints unchanged. Version bump to 4.2.0. + +## 2. EA (bulk-add-signals.mq5) + +Constraints: source stays pure ASCII with CRLF line endings (MetaEditor treats +no-BOM UTF-8 as ANSI on some builds). No new #includes. Fixed-capacity arrays, +not dynamic arrays inside structs. + +- `#define MAX_SPLITS 10`. `SplitOrderGroup` gains `int count`; `tickets`, + `tp_prices` widen to `[MAX_SPLITS]`. +- `TradeCommand` gains `double volume_split[MAX_SPLITS]` and `int tp_count`. + Parser reads up to 10 entries from `tp_levels` and `volume_split`; if + `volume_split` is absent, apply the same default formula as the server + (defense in depth for direct TCP callers). +- EA-side validation mirrors the server: 1..10 levels, split length matches, + each provided TP on the correct side of entry, SL side check as today. +- Volume computation (any-symbol correct): + - `step = SYMBOL_VOLUME_STEP` (fallback 0.01 if broker reports 0), + `minLot = SYMBOL_VOLUME_MIN`. + - `vol_i = MathFloor(lot_size * ratio_i / step + 1e-9) * step`. + - If `ratio_i > 0` and `vol_i < minLot`: reject the whole order; the error + message names the level and the minimum viable `lot_size`. + - Flooring leftover (`lot_size - sum(vol_i)`, floored to step) is added to + the first level having the largest ratio (deterministic on ties). +- Price normalization: entry, SL, and every TP normalized to + `SymbolInfoInteger(sym, SYMBOL_DIGITS)` before `OrderOpen`. This removes the + last symbol-specific assumptions; XAUUSD/XAGUSD become examples, not limits. +- Order comment stays `#` with idx now 1..10; the parser must + read all consecutive digits after `#` (the current code reads exactly one + character and would break at `#10`). Legacy `|GROUP:...|TP:n` parsing kept. +- Trailing semantics unchanged: when the level-2 ticket disappears, move SL of + levels 3..count to the recorded TP1 price. Groups with count < 3, or with + `volume_split[1] == 0`, never trigger (the existing `ticket == 0` guard + already handles this). Safe-shutdown consolidates levels 2..count to the + recorded TP2 price; skipped/unknown levels are ignored. +- Recovery: rebuilt groups set `count = MAX_SPLITS` and rely on `ticket == 0` + guards; fresh groups carry the real count. tp2_reached inference: ticket[1] + gone while any of tickets[2..MAX-1] survive. +- Chart drawing: loop `count`, 10-entry color palette, labels show the actual + volume per level instead of hardcoded percentages. + +## 3. MCP server (mcp_server.py, new file) + +- FastMCP (package `mcp`), stdio transport, wraps the REST API over httpx. +- Env: `MT5_BRIDGE_URL` (default `http://127.0.0.1:8080`), `MT5_API_KEY` + (optional; sent as `X-API-Key`), `MT5_MCP_ENABLE_TRADING` (default off). +- Always-on read-only tools: `list_positions`, `list_pending_orders`, + `account_stats`, `bridge_health`. +- Registered only when `MT5_MCP_ENABLE_TRADING=1`: `place_split_order` + (mirrors /order incl. volume_split), `close_position`, `cancel_order`, + `safe_shutdown`. +- Tool docstrings written for LLM consumption; responses are the bridge's JSON + passed through, with HTTP errors surfaced as readable messages. +- `requirements-mcp.txt`: `mcp`, `httpx`. README gains setup for Claude Code + (`claude mcp add`) and Claude Desktop (config JSON snippet). + +## 4. Tests + CI + +- `tests/test_bridge.py` (pytest + fastapi TestClient + httpx): + - Envelope correlation under two concurrent requests (fake-EA socket helper). + - Timeout -> 504 and the abandoned command is never delivered to a later poll. + - Empty queue poll -> `{"status":"waiting"}`. + - API key: 401 on wrong/missing when set; open when unset. + - Validation matrix: 0 and 11 tp_levels; volume_split length mismatch; + sum out of range; negative entry; all-zero; valid 3-level custom split + (command carries it); legacy 5-level request gets the default injected. +- `requirements-dev.txt`: `pytest`, `httpx`. +- `.github/workflows/ci-python.yml`: ubuntu-latest, Python 3.11, install + requirements + dev, run pytest. Path-triggered on `server.py`, + `mcp_server.py`, `tests/**`, `requirements*.txt`, and the workflow itself. +- Existing compile-ea.yml continues to guard the EA (with /portable). + +## 5. Rollout + +Feature branch -> Opus subagents implement with strict file ownership (no two +agents share a file) -> adversarial review pass -> local EA compile via Wine +MetaEditor /portable + local pytest -> push -> both workflows green -> PR -> +squash-merge (user's standing preference). README documents all new behavior +and keeps a "demo-test before live" warning. + +## Out of scope (deliberate) + +- Configurable trailing trigger/target levels (stays TP2 -> TP1). +- Variable TP counts above 10; per-level SL; strategy-tester support. +- Wiring the currently unused `MaxSpreadPips` input (pre-existing, unchanged). diff --git a/mcp_server.py b/mcp_server.py new file mode 100644 index 0000000..915c2a3 --- /dev/null +++ b/mcp_server.py @@ -0,0 +1,208 @@ +""" +MT5 Trade Split Manager - MCP server + +Exposes the REST bridge (server.py) as Model Context Protocol tools over stdio, +so an LLM client (Claude Code, Claude Desktop) can inspect and control MT5. + +Design notes: +- FastMCP (package `mcp`), stdio transport. NOTHING is printed to stdout: stdio + carries the MCP protocol, so any stray print would corrupt the stream. +- The bridge's JSON is passed straight through on success. HTTP/connection + failures are returned as short human-readable strings, never raw tracebacks, + so the model gets a usable message instead of an exception. +- Read-only tools are always registered. The trading tools (place/close/cancel/ + shutdown) are registered only when MT5_MCP_ENABLE_TRADING is '1' or 'true', + so a default install cannot move money. + +Env: +- MT5_BRIDGE_URL base URL of the REST bridge (default http://127.0.0.1:8080) +- MT5_API_KEY optional; sent as X-API-Key on every request +- MT5_MCP_ENABLE_TRADING '1'/'true' registers the trading tools (default off) +""" + +import os +from typing import List, Optional + +import httpx +from mcp.server.fastmcp import FastMCP + +BRIDGE_URL = os.environ.get("MT5_BRIDGE_URL", "http://127.0.0.1:8080").rstrip("/") +API_KEY = os.environ.get("MT5_API_KEY", "") +TRADING_ENABLED = os.environ.get("MT5_MCP_ENABLE_TRADING", "").lower() in ("1", "true") + +# The bridge abandons a command after ~10s (REQUEST_TIMEOUT); give it headroom. +TIMEOUT = 15.0 + +mcp = FastMCP("MT5 Trade Split Manager") + + +def _headers() -> dict: + return {"X-API-Key": API_KEY} if API_KEY else {} + + +def _format_http_error(resp: httpx.Response): + """Turn a >=400 response into a readable string, preferring the bridge's + own error detail (FastAPI puts it under "detail").""" + try: + body = resp.json() + except Exception: + return f"bridge returned HTTP {resp.status_code}: {resp.text}" + detail = body.get("detail", body) if isinstance(body, dict) else body + return f"bridge returned HTTP {resp.status_code}: {detail}" + + +def _request(method: str, path: str, json_body: Optional[dict] = None): + """Call the REST bridge and return parsed JSON, or a readable error string. + All httpx failures are caught here so tools never leak a traceback.""" + try: + resp = httpx.request( + method, + f"{BRIDGE_URL}{path}", + json=json_body, + headers=_headers(), + timeout=TIMEOUT, + ) + except httpx.ConnectError: + return f"bridge unreachable at {BRIDGE_URL}" + except httpx.TimeoutException: + return f"bridge timed out after {TIMEOUT:.0f}s at {BRIDGE_URL}" + except httpx.HTTPError as e: + return f"bridge request failed: {e}" + + if resp.status_code >= 400: + return _format_http_error(resp) + try: + return resp.json() + except Exception: + return resp.text + + +# --- Read-only tools (always registered) ----------------------------------- + +@mcp.tool() +def list_positions(): + """List all open MT5 positions (the filled legs of split orders). + + Returns the bridge JSON as-is: each position carries its ticket, symbol, + volume in lots, open price, current SL/TP, and floating profit in the + account currency. Read-only; places nothing. + """ + return _request("GET", "/positions") + + +@mcp.tool() +def list_pending_orders(): + """List all pending (not-yet-filled) MT5 orders, e.g. the stop/limit legs + of a split order that price has not reached yet. + + Returns the bridge JSON as-is: ticket, symbol, order type, requested price, + volume in lots, and SL/TP. Read-only; places nothing. + """ + return _request("GET", "/orders") + + +@mcp.tool() +def account_stats(): + """Get MT5 account statistics: balance, equity, margin, free margin, and + open profit, all in the account's deposit currency. Read-only. + """ + return _request("GET", "/stats") + + +@mcp.tool() +def bridge_health(): + """Check that the REST bridge is up and reachable before trading. + + Returns the bridge's health JSON (status, TCP host/port it listens on for + the EA, and whether API-key auth is required). If the bridge process is + down this returns "bridge unreachable at ". Note: a healthy bridge + does not guarantee the MT5 EA is connected - that only shows up when a + command times out. + """ + return _request("GET", "/health") + + +# --- Trading tools (registered only when MT5_MCP_ENABLE_TRADING is on) ------- + +if TRADING_ENABLED: + + @mcp.tool() + def place_split_order( + symbol: str, + order_type: str, + price: float, + sl: float, + tp_levels: List[float], + lot_size: float = 0.1, + volume_split: Optional[List[float]] = None, + comment: str = "Claude AI", + ): + """Place one pending order that the EA splits into several positions, + each closing at a different take-profit level. + + Arguments (prices are in the symbol's quote price, volumes in lots): + - symbol: e.g. "XAUUSD", "EURUSD", "BTCUSD" - any broker symbol. + - order_type: one of BUY_STOP, SELL_STOP, BUY_LIMIT, SELL_LIMIT. + Wrong-side requests are REJECTED by the EA: + * BUY_STOP price must be ABOVE the current ask. + * SELL_STOP price must be BELOW the current bid. + * BUY_LIMIT price must be BELOW the current ask. + * SELL_LIMIT price must be ABOVE the current bid. + - price: the entry price for the pending order. + - sl: stop-loss price. For BUY orders it must be below entry; for SELL + orders above entry, or the EA rejects the order. + - tp_levels: 1 to 10 take-profit prices. For BUY orders every TP must be + above entry; for SELL orders below entry (wrong-side TPs are rejected). + - lot_size: total volume in lots. It is SPLIT across the tp_levels (it is + not per-level). By default TP1 gets 60% and the rest share 40% evenly. + - volume_split (optional): fractions of lot_size, one per tp_level, same + length as tp_levels. Each entry is >= 0, at least one is > 0, and they + must sum to ~1.0 (0.99-1.01). An entry of 0 SKIPS that level (no order + is placed for it). Omit this to use the default 60/40 split. + - comment: label attached to the order group. + + Returns the bridge JSON (per-leg tickets on success). Requires the EA to + be connected; if it is not, this reports a bridge timeout. ALWAYS + demo-test on a demo account before using on a live account. + """ + payload = { + "order_type": order_type, + "symbol": symbol, + "price": price, + "sl": sl, + "tp_levels": tp_levels, + "lot_size": lot_size, + "comment": comment, + } + if volume_split is not None: + payload["volume_split"] = volume_split + return _request("POST", "/order", payload) + + @mcp.tool() + def close_position(ticket: int): + """Close an open MT5 position by its ticket number (one leg of a split + order). Get ticket numbers from list_positions. This closes at market; + it is immediate and cannot be undone. + """ + return _request("DELETE", f"/position/{ticket}") + + @mcp.tool() + def cancel_order(ticket: int): + """Cancel a pending (not-yet-filled) MT5 order by its ticket number. Get + ticket numbers from list_pending_orders. This only removes an unfilled + order; it does not touch already-open positions. + """ + return _request("DELETE", f"/order/{ticket}") + + @mcp.tool() + def safe_shutdown(): + """Consolidate the remaining take-profit legs before the bridge/EA goes + offline: the EA moves the TP of levels 2..N to the recorded TP2 price so + open positions still have protection when no software is watching. + Skipped or unknown levels are ignored. Does not close positions. + """ + return _request("POST", "/safe-shutdown") + + +if __name__ == "__main__": + mcp.run() diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..2fb4e83 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +pytest>=7.4 +httpx>=0.27,<0.28 diff --git a/requirements-mcp.txt b/requirements-mcp.txt new file mode 100644 index 0000000..8aaff37 --- /dev/null +++ b/requirements-mcp.txt @@ -0,0 +1,2 @@ +mcp>=1.2.0 +httpx>=0.27.0 diff --git a/server.py b/server.py index d1f7e83..7722529 100644 --- a/server.py +++ b/server.py @@ -18,7 +18,7 @@ import time from fastapi import FastAPI, HTTPException, Header, Depends -from pydantic import BaseModel +from pydantic import BaseModel, field_validator, model_validator from typing import List, Optional import uvicorn @@ -45,7 +45,7 @@ if not API_KEY and HTTP_HOST != '127.0.0.1': print("⚠️ WARNING: HOST is not localhost and API_KEY is unset - the trading API is UNAUTHENTICATED.") -app = FastAPI(title="MT5 TCP Bridge", version="4.1.0") +app = FastAPI(title="MT5 TCP Bridge", version="4.2.0") class OrderCommand(BaseModel): @@ -55,12 +55,51 @@ class OrderCommand(BaseModel): price: float sl: float tp_levels: List[float] + volume_split: Optional[List[float]] = None lot_size: float = 0.1 deviation: int = 3 comment: str = "Claude AI" magic_number: int = 20250117 partial_close_percent: float = 20.0 + @field_validator("tp_levels") + @classmethod + def _validate_tp_levels(cls, v: List[float]) -> List[float]: + if not 1 <= len(v) <= 10: + raise ValueError(f"tp_levels must contain 1 to 10 prices (got {len(v)})") + return v + + @model_validator(mode="after") + def _validate_volume_split(self) -> "OrderCommand": + # An omitted split is filled in with the default at send time. + if self.volume_split is None: + return self + split = self.volume_split + if len(split) != len(self.tp_levels): + raise ValueError( + f"volume_split length ({len(split)}) must match tp_levels " + f"length ({len(self.tp_levels)})" + ) + if any(x < 0 for x in split): + raise ValueError("volume_split entries must all be >= 0") + if not any(x > 0 for x in split): + raise ValueError("volume_split must have at least one entry > 0") + total = sum(split) + if not 0.99 <= total <= 1.01: + raise ValueError( + f"volume_split must sum to ~1.0 within [0.99, 1.01] (got {total:.4f})" + ) + return self + + +def default_volume_split(n: int) -> List[float]: + """Legacy-compatible default split for N levels: N=1 -> [1.0]; otherwise + TP1 = 0.60 and each remaining level = 0.40 / (N-1). For N=5 this is the + original 60/10/10/10/10 weighting, so existing clients are unchanged.""" + if n == 1: + return [1.0] + return [0.60] + [0.40 / (n - 1)] * (n - 1) + class Envelope: """One in-flight command plus the machinery to deliver its response back to @@ -196,7 +235,12 @@ async def health(): @app.post("/order", dependencies=[Depends(require_key)]) async def create_order(order: OrderCommand): - """Place order on MT5 via TCP (split into 5 positions by the EA).""" + """Place order on MT5 via TCP (split into 1-10 positions by the EA).""" + # The command sent to the EA always carries an explicit volume_split; when + # the client omits it we inject the legacy-compatible default here. + volume_split = order.volume_split + if volume_split is None: + volume_split = default_volume_split(len(order.tp_levels)) command = { "action": "PLACE_ORDER", "data": { @@ -205,6 +249,7 @@ async def create_order(order: OrderCommand): "price": order.price, "sl": order.sl, "tp_levels": order.tp_levels, + "volume_split": volume_split, "lot_size": order.lot_size, "deviation": order.deviation, "comment": order.comment, diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_bridge.py b/tests/test_bridge.py new file mode 100644 index 0000000..143fba3 --- /dev/null +++ b/tests/test_bridge.py @@ -0,0 +1,296 @@ +"""Unit tests for the FastAPI/TCP bridge in server.py. + +Environment facts these tests are built around: +- server.py loads config.json and binds the TCP listener (port 5555) inside a + daemon thread at IMPORT time, so we chdir to the repo root before importing it + and import it exactly once for the whole session. We never importlib.reload it + (that would try to re-bind the port and fail). +- API_KEY is read into a module global at import. require_key reads that global + at call time, so the auth test patches server.API_KEY directly (monkeypatch + restores it) rather than reloading the module. +""" + +import asyncio +import json +import os +import queue +import socket +import sys +import threading +import time + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +# server.py does open('config.json') and binds the TCP port at import time, so +# be in the repo root and importable before importing it. +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +os.chdir(_REPO_ROOT) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +import server # noqa: E402 (import must follow the chdir above) + + +# --------------------------------------------------------------------------- # +# Fixtures +# --------------------------------------------------------------------------- # +@pytest.fixture(scope="session", autouse=True) +def _tcp_ready(): + """Block until the background TCP server thread is accepting connections.""" + deadline = time.time() + 10 + last_err = None + while time.time() < deadline: + try: + with socket.create_connection((server.TCP_HOST, server.TCP_PORT), timeout=1): + return + except OSError as exc: # not listening yet + last_err = exc + time.sleep(0.05) + raise RuntimeError(f"TCP bridge never started listening: {last_err}") + + +@pytest.fixture(autouse=True) +def _drain_queue(): + """Drain any leftover (possibly abandoned) command between tests so state + from one test - e.g. the abandoned envelope left by the timeout test - can + never leak into the next one.""" + _drain(server.command_queue) + yield + _drain(server.command_queue) + + +@pytest.fixture(scope="session") +def client(): + return TestClient(server.app) + + +# --------------------------------------------------------------------------- # +# Socket / helper utilities +# --------------------------------------------------------------------------- # +def _drain(q): + while True: + try: + q.get_nowait() + except queue.Empty: + return + + +def _recv_json(sock, timeout=3): + """Read from a socket until a complete JSON object arrives (mirrors how the + bridge itself frames messages) or the peer closes / times out.""" + sock.settimeout(timeout) + data = b"" + while True: + try: + chunk = sock.recv(4096) + except socket.timeout: + break + if not chunk: + break + data += chunk + try: + return json.loads(data.decode("utf-8")) + except json.JSONDecodeError: + continue + try: + return json.loads(data.decode("utf-8")) + except Exception: + return None + + +def _poll_once(timeout=3): + """Act as the EA polling the bridge exactly once; return what it sends back.""" + with socket.create_connection((server.TCP_HOST, server.TCP_PORT), timeout=timeout) as sock: + return _recv_json(sock, timeout=timeout) + + +def _fake_ea_serve(count, deadline): + """Stand in for the EA: connect to the bridge repeatedly and, for each real + command received, echo a response tagged with the command's own ``tag`` so a + test can verify each caller gets back *its own* response. Empty-queue + ("waiting") replies are skipped and retried until ``count`` commands have + been served or the deadline passes. Returns the number actually served.""" + served = 0 + while served < count and time.time() < deadline: + try: + sock = socket.create_connection((server.TCP_HOST, server.TCP_PORT), timeout=1) + except OSError: + time.sleep(0.02) + continue + try: + msg = _recv_json(sock, timeout=2) + if not msg or msg.get("status") == "waiting" or "action" not in msg: + continue # empty queue - reconnect and retry + reply = {"success": True, "tag": msg.get("tag"), "seen_action": msg.get("action")} + sock.sendall(json.dumps(reply).encode("utf-8")) + served += 1 + finally: + sock.close() + if served < count: + time.sleep(0.01) + return served + + +def _order_payload(**overrides): + """A minimal, otherwise-valid POST /order body; override individual fields.""" + payload = { + "order_type": "BUY_LIMIT", + "symbol": "XAUUSD", + "price": 2000.0, + "sl": 1990.0, + "tp_levels": [2010.0, 2020.0], + } + payload.update(overrides) + return payload + + +# --------------------------------------------------------------------------- # +# 1. Envelope correlation under concurrent requests +# --------------------------------------------------------------------------- # +def test_envelope_correlation_concurrent(): + """Two concurrent callers must each receive the response to their *own* + command, never each other's (the per-request Envelope guarantee).""" + results = {} + errors = {} + + def _caller(tag): + try: + results[tag] = server.send_command_to_mt5({"action": "PING", "tag": tag}, timeout=5) + except Exception as exc: # noqa: BLE001 - surfaced via the errors dict + errors[tag] = exc + + threads = [threading.Thread(target=_caller, args=(tag,)) for tag in ("A", "B")] + for thread in threads: + thread.start() + + served = _fake_ea_serve(count=2, deadline=time.time() + 8) + + for thread in threads: + thread.join(timeout=8) + + assert served == 2, f"fake EA only served {served}/2 commands" + assert not errors, f"callers raised unexpectedly: {errors}" + assert results["A"]["tag"] == "A" + assert results["B"]["tag"] == "B" + assert results["A"]["seen_action"] == "PING" + + +# --------------------------------------------------------------------------- # +# 2. Timeout -> 504 and the abandoned command is never delivered +# --------------------------------------------------------------------------- # +def test_timeout_abandons_command(): + # No EA is polling, so the request must time out as a 504. + with pytest.raises(HTTPException) as excinfo: + server.send_command_to_mt5({"action": "PING", "tag": "orphan"}, timeout=1) + assert excinfo.value.status_code == 504 + + # The abandoned command must never be handed to the EA: the next poll, which + # pulls and skips the abandoned envelope, sees an empty queue instead. + assert _poll_once() == {"status": "waiting"} + + +# --------------------------------------------------------------------------- # +# 3. Empty-queue poll -> waiting +# --------------------------------------------------------------------------- # +def test_empty_queue_poll_returns_waiting(): + assert _poll_once() == {"status": "waiting"} + + +# --------------------------------------------------------------------------- # +# 4. API-key auth +# --------------------------------------------------------------------------- # +def test_api_key_enforced_when_set(client, monkeypatch): + monkeypatch.setattr(server, "API_KEY", "s3cret") + + # Missing key on a trading endpoint -> 401. + assert client.get("/positions").status_code == 401 + # Wrong key -> 401. + assert client.get("/positions", headers={"X-API-Key": "nope"}).status_code == 401 + + # /health never requires a key, even while auth is active. + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json()["auth_required"] is True + + +# --------------------------------------------------------------------------- # +# 5a. Validation matrix (fails fast at pydantic -> 422, no EA needed) +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "overrides", + [ + {"tp_levels": []}, # 0 levels + {"tp_levels": [float(i) for i in range(1, 12)]}, # 11 levels + {"tp_levels": [2010.0, 2020.0, 2030.0], "volume_split": [0.6, 0.4]}, # length mismatch + {"tp_levels": [2010.0, 2020.0], "volume_split": [0.3, 0.2]}, # sum 0.5, out of range + {"tp_levels": [2010.0, 2020.0], "volume_split": [-0.1, 1.1]}, # negative entry + {"tp_levels": [2010.0, 2020.0], "volume_split": [0.0, 0.0]}, # all-zero + ], + ids=[ + "zero-tp-levels", + "eleven-tp-levels", + "split-length-mismatch", + "split-sum-out-of-range", + "split-negative-entry", + "split-all-zero", + ], +) +def test_order_validation_rejected(client, overrides): + resp = client.post("/order", json=_order_payload(**overrides)) + assert resp.status_code == 422, resp.text + + +# --------------------------------------------------------------------------- # +# 5b. Happy paths - assert the built command dict directly (not over HTTP, which +# would block waiting on the EA). We invoke the real handler with the TCP +# send patched out and inspect the command it built. +# --------------------------------------------------------------------------- # +def _capture_command(monkeypatch, order): + captured = {} + + def _fake_send(command, timeout=None): + captured["command"] = command + return {"success": True} + + monkeypatch.setattr(server, "send_command_to_mt5", _fake_send) + asyncio.run(server.create_order(order)) + return captured["command"] + + +def test_custom_split_carried_through(monkeypatch): + order = server.OrderCommand( + order_type="BUY_LIMIT", + symbol="XAUUSD", + price=2000.0, + sl=1990.0, + tp_levels=[2010.0, 2020.0, 2030.0], + volume_split=[0.5, 0.3, 0.2], + ) + # A valid custom split survives validation unchanged... + assert order.volume_split == [0.5, 0.3, 0.2] + # ...and reaches the EA command verbatim. + command = _capture_command(monkeypatch, order) + assert command["data"]["volume_split"] == [0.5, 0.3, 0.2] + assert command["data"]["tp_levels"] == [2010.0, 2020.0, 2030.0] + + +def test_legacy_default_split_injected(monkeypatch): + order = server.OrderCommand( + order_type="BUY_LIMIT", + symbol="XAUUSD", + price=2000.0, + sl=1990.0, + tp_levels=[2010.0, 2020.0, 2030.0, 2040.0, 2050.0], + ) + # The client omitted volume_split, so the model leaves it None... + assert order.volume_split is None + # ...and the default is the legacy 60/10/10/10/10 weighting for N=5. + assert server.default_volume_split(5) == [0.6, 0.1, 0.1, 0.1, 0.1] + # The handler injects that default into the command it sends to the EA. + command = _capture_command(monkeypatch, order) + assert command["data"]["volume_split"] == [0.6, 0.1, 0.1, 0.1, 0.1] + # Tolerant float check: naive summation (Python <= 3.11) yields + # 0.9999999999999999 while 3.12+ Neumaier summation yields exactly 1.0. + assert abs(sum(command["data"]["volume_split"]) - 1.0) < 1e-9