Content
# mt5-httpapi
MetaTrader 5 running inside a real Windows VM (Docker + QEMU/KVM) with a REST API slapped on top for programmatic trading. No Wine bullshit, no janky workarounds - a legit Windows environment running the full MT5 terminal in portable mode.
Supports multiple brokers and multiple accounts on the same VM simultaneously. Each terminal gets its own Python API process inside the VM, and an always-on nginx sidecar fronts them all behind a single host port at `http://localhost:8888/<broker>/<account>/...`. Run two FTMO challenges at once, or mix brokers - whatever you need.
**Built-in technical analysis.** One `POST /symbols/<symbol>/rates/ta` returns OHLC bars *and* the indicators you asked for — RSI, MACD, Bollinger, ADX, VWAP, Ichimoku, Order Blocks, Fair Value Gaps, BOS/CHoCH, swing structure, S/R levels, dozens more — in a single round-trip. The [wickworks](https://github.com/psyb0t/docker-wickworks) sidecar lives inside the mt5 container's net namespace, so nothing outside the API can reach it. Primitives only — raw indicator series and structural facts, no interpretive signals (build those in your consumer). No client-side TA stack, no pandas pipeline, no `pip install` dance — just ask for the indicators you need and get them back with the bars. See [Technical Analysis](#technical-analysis).
## Table of Contents
- [Disclaimer](#-disclaimer)
- [Recommended Brokers](#recommended-brokers)
- [Requirements](#requirements)
- [When 512M is NOT enough](#when-512m-is-not-enough)
- [Real-world usage](#real-world-usage-4-terminals-on-2-vcpus--512m-ram-light-load-only)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [`config/config.yaml`](#configconfigyaml)
- [`config/setup.bat`](#configsetupbat)
- [`mt5installers/`](#mt5installers)
- [API](#api)
- [Backtest](#backtest)
- [Health](#health)
- [Terminal](#terminal)
- [Account](#account)
- [Broker time vs real UTC](#broker-time-vs-real-utc)
- [Symbols](#symbols) — list, info, ticks, **rates**, **rates + TA**
- [Positions](#positions)
- [Orders](#orders)
- [Trade Result](#trade-result)
- [History](#history)
- [MCP Interface](#mcp-interface)
- [Examples](#examples)
- [Optimization Guide](#optimization-guide)
- [Go Client](#go-client)
- [Technical Analysis](#technical-analysis) — server-side via wickworks, or client-side with pandas-ta
- [Make Targets](#make-targets)
- [Ports](#ports)
- [Tailscale (optional)](#tailscale-optional)
- [Cloudflare Tunnel (optional)](#cloudflare-tunnel-optional)
- [Project Structure](#project-structure)
- [Concurrency & Backpressure](#concurrency--backpressure)
- [Logs](#logs)
- [License](#license)
## ⚠️ Disclaimer
This is a tool for automating trades. If you blow your account, that's on you. Use demo accounts first, test your shit, and don't come crying when your algo buys the top.
## Recommended Brokers
- [RoboForex](https://my.roboforex.com/en/?a=zswg)
- [TeleTrade](https://my.teletrade-dj.com/agent_pp.html?agent_pp=26834897)
## Requirements
- Linux host with KVM enabled (`/dev/kvm`)
- Docker + Docker Compose
- ~20 GB disk (4 GB ISO + 11 GB VM + MT5 installs)
- 5 GB RAM (for the Windows VM)
The container ships with a `512M` memory limit and `5G` memswap limit — so the VM runs mostly on host swap. tiny11 + the debloat script idles at ~1.4 GB, MT5 + the Python API add a bit on top. For low-volume use (placing trades, polling positions, pulling small recent candle windows) this is fine — it's not latency-sensitive enough for swap to matter. noVNC is there so you can watch the installation progress; after that, forget the UI exists and just hit the REST API.
### When 512M is NOT enough
MT5 terminals cache every loaded chart in process RAM and never release it. The moment you start backfilling deep history (e.g. scraping all symbols × multiple timeframes × years of data) each terminal balloons to multi-GB. With a 512M container limit that all gets paged to host swap, Windows guest memory manager doesn't know the pages are on disk, processes appear unresponsive, and Windows starts trimming/killing them silently. The cmd.exe wrappers stay open but blank, the Python API processes are dead, and you get a hung tunnel.
If you're doing **heavy historical data scraping**, do at least one of:
- Bump the container memory limit to match real demand (4–8 GB for multi-terminal heavy scraping).
- Scrape one broker at a time (stop the others) so only one terminal accumulates cache.
- Restart the terminal between batches via `POST /terminal/restart` to flush the chart cache.
- Chunk time ranges instead of pulling 10 years of M1 in one shot.
Or just run MT5 on a dedicated box if you're hammering it.
### Real-world usage: 4 terminals on 2 vCPUs + 512M RAM (light load only)

4 MT5 terminals (RoboForex, 2x TeleTrade, FTMO) running simultaneously on 2 vCPUs with only 512M real RAM. CPU spikes to 100% during startup, drops to ~15% idle. Total memory usage: 2.1 GB, comfortably on swap. This works **as long as you're not stress-loading it with deep history scrapes** — at idle / light polling, you can pack 10+ terminals in here.
## Quick Start
```bash
# 1. Create your config from the template
cp config/config.yaml.example config/config.yaml
# Edit config/config.yaml: api_token, accounts, terminals (see below)
# 2. Drop your broker's MT5 installer in mt5installers/
# Name it: mt5setup-<broker>.exe
cp ~/Downloads/mt5setup.exe mt5installers/mt5setup-roboforex.exe
# 3. Fire it up
make up
```
First run downloads [tiny11](https://archive.org/details/tiny-11-NTDEV) (stripped-down Windows 11, ~4 GB), installs it (~10 min), then sets up Python + MT5 automatically. After that, boots in ~1 min. Go grab a coffee on the first run.
## Configuration
### `config/config.yaml`
Single source of truth — gitignored. Copy `config/config.yaml.example` and edit. Structure:
```yaml
# Bearer token for API auth. Empty = no auth.
api_token: "paste-the-output-of-openssl-rand-hex-32-here"
# VM auto-reboot every N minutes (flushes DWM/VirtIO-GPU state). 0 = disable.
reboot_interval: 30
# Default Strategy Tester timeout. POST /backtest can override per job.
backtest_timeout: "6h"
tailscale:
auth_key: "" # tskey-auth-... — empty disables the tailscale sidecar
login_server: "" # Headscale URL; empty = Tailscale cloud
# Extra pip packages for the VM. MetaTrader5/flask/waitress/flask-compress are always installed.
requirements: []
# Broker credentials, organized by broker → account_name.
accounts:
roboforex:
main:
login: 12345678
server: "RoboForex-Pro"
password: "your_password"
demo:
login: 87654321
server: "RoboForex-Demo"
password: "demo_password"
# Terminal instances — one MT5 process + one API process per entry.
terminals:
- broker: roboforex
account: main
port: 6542
utc_offset: "3h"
symbol_suffix: ""
- broker: roboforex
account: demo
port: 6543
utc_offset: "3h"
symbol_suffix: ""
- broker: roboforex
account: tester
port: 6544
utc_offset: "3h"
mode: backtest # don't auto-launch terminal64.exe; reserved for /backtest jobs
symbol_suffix: ".r" # optional explicit suffix for tester symbol remap
```
Per-field notes:
- **`api_token`** — if set, every endpoint requires `Authorization: Bearer <token>`. Empty = open. Generate with `openssl rand -hex 32`.
- **`reboot_interval`** — minutes between scheduled VM reboots. `0` disables.
- **`backtest_timeout`** — default Strategy Tester timeout for `POST /backtest`. Accepts the same duration grammar as `utc_offset`: `"6h"`, `"30m"`, `"3h30m"`, `"90m"`, or a bare number interpreted as hours. Per-request form field `timeout` overrides it.
- **`tailscale.auth_key`** / **`tailscale.login_server`** — see [Tailscale](#tailscale-optional). Empty `auth_key` skips the sidecar.
- **`requirements`** — additional pip packages installed in the VM on every boot.
- **`accounts.<broker>.<account>`** — `broker` must match the installer name (`mt5setup-<broker>.exe`) and the `broker` field in `terminals[]`. `account` must match the `account` field in `terminals[]`.
- **`terminals[].port`** — container-internal port for this terminal's HTTP API. Only nginx and the mt5 container talk to it; not exposed to the host.
- **`terminals[].instance`** — optional terminal clone name. Use this when the same `broker` / `account` appears more than once. There is no `queue` field in `config.yaml`; callers select a specific clone by routing requests to `/<broker>/<account>/<instance>/...`. Missing/empty = `default`, and that default instance also keeps the legacy `/<broker>/<account>/...` alias.
- **`terminals[].utc_offset`** — broker server's UTC offset, used to normalize all timestamps to real UTC on the wire (see [Broker time vs real UTC](#broker-time-vs-real-utc) below). Optional — defaults to `0`. Accepts `"3h"`, `"3h30m"`, `"-2h"`, `"90m"`, or a bare number (interpreted as hours). Common values: RoboForex/FTMO `"3h"`, TeleTrade `"2h"`.
- **`terminals[].mode`** — `live` (default) or `backtest`. `live` keeps `terminal64.exe` running so the MT5 SDK stays initialized for live trading endpoints. `backtest` prepares the same portable directory but does **not** launch `terminal64.exe`, leaving the data dir free for the Strategy Tester subprocess to grab — see [Backtest](#backtest). MT5 is single-instance per portable data dir, so a backtest cannot run against a `live` terminal.
- **`terminals[].symbol_suffix`** — optional explicit symbol suffix for Strategy Tester remaps. If set, mt5-httpapi appends it when `[Tester].Symbol` does not already end with that suffix. Examples: `"p"`, `".p"`, `"-mini"`. Use `""` for no suffix.
Each terminal installs to `<broker>/base/` and gets copied to `<broker>/<account>/` at startup so multiple accounts of the same broker don't step on each other.
### `config/setup.bat`
Custom commands that run on every VM boot before MT5 starts. Shove whatever Windows setup shit you need in here.
### `mt5installers/`
Dump your broker MT5 installers here. Name them `mt5setup-<broker>.exe` and each one gets its own portable install automatically.
## API
All terminals are served behind a single host port via nginx. Default entry point: `http://localhost:8888` (loopback-only). Each terminal lives at its own path prefix:
```
http://localhost:8888/<broker>/<account>/...
```
When you configure explicit terminal instances, route to them like this:
```
http://localhost:8888/<broker>/<account>/<instance>/...
```
Example: with a `roboforex/main` terminal in `config.yaml`'s `terminals:` list, hit `http://localhost:8888/roboforex/main/ping`. The `/<broker>/<account>/` prefix is stripped by nginx and the rest is proxied to that terminal's API process inside the VM.
If `api_token` is set in `config.yaml`, include the token on every request:
```bash
export MT5_API_TOKEN=$(grep ^api_token config/config.yaml | awk -F'"' '{print $2}')
curl -H "Authorization: Bearer $MT5_API_TOKEN" http://localhost:8888/roboforex/main/ping
```
### Health
| Method | Endpoint | Description |
| ------ | -------- | ----------------- |
| GET | `/ping` | Is this thing on? |
| GET | `/error` | Last MT5 error |
**GET `/ping`**:
```json
{ "status": "ok" }
```
**GET `/error`**:
```json
{ "code": 1, "message": "Success" }
```
### Terminal
| Method | Endpoint | Description |
| ------ | -------------------- | ------------------------- |
| GET | `/terminal` | Terminal info |
| POST | `/terminal/init` | Initialize MT5 connection |
| POST | `/terminal/shutdown` | Kill MT5 |
**GET `/terminal`**:
```json
{
"build": 5602,
"codepage": 0,
"commondata_path": "C:\\Users\\Docker\\AppData\\Roaming\\MetaQuotes\\Terminal\\Common",
"community_account": false,
"community_balance": 0.0,
"community_connection": false,
"company": "Your Broker Inc.",
"connected": true,
"data_path": "C:\\Users\\Docker\\Desktop\\Shared\\mybroker",
"dlls_allowed": true,
"email_enabled": false,
"ftp_enabled": false,
"language": "English",
"maxbars": 100000,
"mqid": false,
"name": "MyBroker MetaTrader 5",
"notifications_enabled": false,
"path": "C:\\Users\\Docker\\Desktop\\Shared\\mybroker",
"ping_last": 0,
"retransmission": 0.003,
"trade_allowed": true,
"tradeapi_disabled": false,
"broker_utc_offset_hours": 3,
"broker_utc_offset_seconds": 10800
}
```
**POST `/terminal/init`** and **POST `/terminal/shutdown`**:
```json
{ "success": true }
```
The API auto-initializes on first request. You almost never need to call these manually.
### Account
| Method | Endpoint | Description |
| ------ | ---------- | -------------------- |
| GET | `/account` | Current account info |
**GET `/account`**:
```json
{
"login": 12345678,
"name": "Your Name",
"server": "MyBroker-Server",
"company": "Your Broker Inc.",
"currency": "USD",
"currency_digits": 2,
"balance": 10000.0,
"credit": 0.0,
"profit": 0.0,
"equity": 10000.0,
"margin": 0.0,
"margin_free": 10000.0,
"margin_level": 0.0,
"margin_initial": 0.0,
"margin_maintenance": 0.0,
"margin_so_call": 70.0,
"margin_so_so": 20.0,
"margin_so_mode": 0,
"margin_mode": 2,
"assets": 0.0,
"liabilities": 0.0,
"commission_blocked": 0.0,
"leverage": 500,
"limit_orders": 0,
"trade_allowed": true,
"trade_expert": true,
"trade_mode": 0,
"fifo_close": false
}
```
### Broker time vs real UTC
MT5 has a notorious timezone gotcha: every timestamp it returns (tick `time`, rate `time`, position `time`, deal `time_msc`, etc.) is the **broker server's wall-clock time**, encoded as a unix integer. Looks like UTC, isn't. RoboForex/FTMO run UTC+3, TeleTrade UTC+2 — so a tick captured at real UTC `22:57` reports as unix `01:57` (3h ahead) on RoboForex, `00:57` (2h ahead) on TeleTrade.
The MT5 Python SDK doesn't expose `TimeCurrent()` / `TimeGMT()`, so the API can't auto-detect this. Instead, set `utc_offset` per terminal in `config.yaml`:
```yaml
terminals:
- broker: roboforex
account: main
port: 6542
utc_offset: "3h"
```
When set, the API:
- Subtracts the offset from every outgoing timestamp (tick time, rate time, position/order/deal times, `_msc` fields), so responses are real UTC unix.
- Adds the offset to incoming `from`/`to` query params, so callers always pass real UTC unix and get back real UTC unix.
Inspect via `GET /terminal` — fields `broker_utc_offset_hours` and `broker_utc_offset_seconds` show what's in effect.
If `utc_offset` is omitted (or `0`), the API passes raw broker timestamps through unchanged (pre-1.8 behavior).
### Symbols
| Method | Endpoint | Description |
| ------ | ------------------------ | ----------------------------------------- |
| GET | `/symbols` | List symbols (`?group=*USD*`) |
| GET | `/symbols/:symbol` | Symbol details |
| GET | `/symbols/:symbol/tick` | Latest tick |
| GET | `/symbols/:symbol/rates` | OHLCV candles (`?timeframe=H1&count=100`, `?timeframe=H1&from=<unix>&count=-100`, or `?timeframe=H1&from=<unix>&to=<unix>`) |
| POST | `/symbols/:symbol/rates/ta` | Same query params as `/rates`; JSON body `{indicators: {...}, recentBars?: N}`. Returns bars + wickworks TA analysis. |
| GET | `/symbols/:symbol/ticks` | Tick data (`?count=100`, `?from=<unix>&count=-100`, or `?from=<unix>&to=<unix>`) |
**GET `/symbols`** — array of symbol names:
```json
["EURUSD", "GBPUSD", "ADAUSD", "BTCUSD", "..."]
```
**GET `/symbols/:symbol`** — full symbol info:
```json
{
"name": "EURUSD",
"description": "Euro vs US Dollar",
"path": "Markets\\Forex\\Major\\EURUSD",
"currency_base": "EUR",
"currency_profit": "USD",
"currency_margin": "EUR",
"digits": 5,
"point": 1e-05,
"spread": 30,
"spread_float": true,
"trade_contract_size": 100000.0,
"trade_tick_size": 1e-05,
"trade_tick_value": 1.0,
"trade_tick_value_profit": 1.0,
"trade_tick_value_loss": 1.0,
"volume_min": 0.01,
"volume_max": 100.0,
"volume_step": 0.01,
"volume_limit": 0.0,
"trade_mode": 4,
"trade_calc_mode": 0,
"trade_exemode": 2,
"trade_stops_level": 1,
"trade_freeze_level": 0,
"swap_long": -11.0,
"swap_short": 1.14064,
"swap_mode": 1,
"swap_rollover3days": 3,
"margin_initial": 0.0,
"margin_maintenance": 0.0,
"margin_hedged": 50000.0,
"filling_mode": 3,
"expiration_mode": 15,
"order_gtc_mode": 0,
"order_mode": 127,
"bid": 1.18672,
"ask": 1.18702,
"bidhigh": 1.18845,
"bidlow": 1.1847,
"askhigh": 1.1885,
"asklow": 1.18475,
"last": 0.0,
"time": 1771027139,
"select": true,
"visible": true,
"custom": false,
"session_deals": 0,
"session_buy_orders": 0,
"session_sell_orders": 0,
"session_open": 1.1869,
"session_close": 1.18698,
"price_change": -0.0219,
"bank": "",
"basis": "",
"category": "",
"exchange": "",
"isin": "",
"..."
}
```
There's a shitload of fields — these are the ones you'll actually use:
| Field | What it is |
| ----------------------------------------- | ------------------------------------------ |
| `bid`, `ask` | Current prices |
| `digits` | Price decimal places |
| `point` | Smallest price change |
| `trade_tick_size` | Minimum price movement |
| `trade_tick_value` | Profit/loss per tick per 1 lot |
| `trade_contract_size` | Contract size (100000 for forex) |
| `volume_min`, `volume_max`, `volume_step` | Lot size constraints |
| `spread` | Current spread in points |
| `swap_long`, `swap_short` | Overnight swap rates |
| `trade_stops_level` | Min distance for SL/TP from price (points) |
**GET `/symbols/:symbol/tick`**:
```json
{
"time": 1771150549,
"bid": 0.3001,
"ask": 0.3004,
"last": 0.0,
"volume": 0,
"time_msc": 1771150549145,
"flags": 1030,
"volume_real": 0.0
}
```
**GET `/symbols/:symbol/rates`** — array of OHLCV candles:
Timeframes: `M1` `M2` `M3` `M4` `M5` `M6` `M10` `M12` `M15` `M20` `M30` `H1` `H2` `H3` `H4` `H6` `H8` `H12` `D1` `W1` `MN1`
```json
{
"time": 1771128000,
"open": 0.2962,
"high": 0.3006,
"low": 0.2922,
"close": 0.2979,
"tick_volume": 4755,
"spread": 30,
"real_volume": 0
}
```
`time` is the candle open time, unix epoch seconds.
Query params (rates):
| Param | Behavior |
| --- | --- |
| `timeframe` | Defaults `M1` |
| `count` | Signed integer (default `100`). Positive = N forward from `from` (or last N if no `from`). Negative = `\|N\|` ending at `from`. Zero = empty result. Mutually exclusive with `to`. |
| `from` | Anchor (real UTC). Omitted = now. Accepts unix seconds, `YYYY_MM_DD_HH_MM_SS`, or `YYYY_MM_DD` (midnight UTC). |
| `to` | Range end (real UTC, same formats as `from`). Requires `from`. Returns all bars in `[from, to]`, no count cap beyond `terminal_info().maxbars`. Mutually exclusive with `count`. |
Examples:
- `?timeframe=H1&count=100` — last 100 H1 candles up to current bar
- `?timeframe=H1&from=1700000000&count=100` — 100 candles forward from anchor
- `?timeframe=H1&from=2024_01_15&count=-100` — 100 candles ending at midnight UTC on 2024-01-15
- `?timeframe=H1&from=2024_01_15_09_30_00&to=2024_01_15_16_00_00` — every H1 candle in the window
Pick the mode that fits: `count` when you want exactly N bars and don't care about the end time; `to` when you have an explicit window and want everything in it. The `count` mode internally computes the range with weekend/holiday padding; the `to` mode is a direct passthrough to `copy_rates_range`.
**MaxBars cap:** MT5 returns at most `terminal_info().maxbars` rows per request (default 100,000 — visible at `GET /terminal`). For long backfills (e.g. M1 over a year ≈ 525k bars) chunk the time range client-side and stitch the results.
Symbols are auto-selected into MarketWatch on first access — backfilling rarely-traded instruments works without a manual select step.
**POST `/symbols/:symbol/rates/ta`** — fetches candles exactly like `GET /symbols/:symbol/rates` (same query params: `timeframe`, `count`, `from`, `to`), then forwards them to the [wickworks](https://github.com/psyb0t/docker-wickworks) TA sidecar for indicator analysis. Returns both as siblings:
```json
{
"symbol": "EURUSD",
"timeframe": "H1",
"bars": [ { "time": 1771146000, "open": 1.0832, "high": 1.0840, "low": 1.0828, "close": 1.0835, "tick_volume": 1234, "spread": 1, "real_volume": 0 } ],
"ta": { "indicators": { "rsi": [ ... ], "macd": { ... } }, "...": "wickworks response" }
}
```
JSON body:
| Field | Required | Meaning |
| --- | --- | --- |
| `indicators` | yes | Non-empty object — wickworks indicator spec. Each entry maps an output key to either `true` (run with defaults) or a flat params object (e.g. `{"length": 20, "std": 2}`); add `"type": "<name>"` only when the output key differs from the indicator name (e.g. `{"rsi21": {"type": "rsi", "length": 21}}` to run a second RSI under a custom key). See the [wickworks indicator catalog](https://github.com/psyb0t/docker-wickworks#available-indicators) for the full list of types, params, and output shapes. |
| `recentBars` | no | Currently **inert** in wickworks v0.3.0 — accepted by the request schema but unused (reserved for future signal-tagged outputs). To get fewer bars back, lower `count` on the query string or slice client-side. |
The sidecar runs inside the mt5 container's net namespace with no published ports — only the mt5 process (and by extension this API) can reach it. Configure via `wickworks:` in `config.yaml` (defaults to `http://20.20.20.1:8000/`, the dockurr gateway IP seen from inside the Windows VM).
Example:
```bash
curl -X POST -H "Authorization: Bearer $MT5_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"indicators":{"rsi":true,"macd":true},"recentBars":50}' \
"$MT5_API_URL/symbols/EURUSD/rates/ta?timeframe=H1&count=200"
```
**GET `/symbols/:symbol/ticks`** — array of ticks:
```json
{
"time": 1771146325,
"bid": 0.2973,
"ask": 0.2976,
"last": 0.0,
"volume": 0,
"time_msc": 1771146325123,
"flags": 6,
"volume_real": 0.0
}
```
Query params (ticks): same `count` / `from` / `to` model as rates — positive `count` = forward from `from`, negative = backward, `from+to` = range, `count` and `to` mutually exclusive, `to` requires `from`. Plus:
| Param | Values | Default | Meaning |
| --- | --- | --- | --- |
| `flags` | `ALL`, `INFO`, `TRADE` | `ALL` | `INFO` = bid/ask changes only (~10× smaller payload), `TRADE` = trades only, `ALL` = everything |
Examples:
- `?count=100` — last 100 ticks up to now
- `?from=2024_01_15_14_30_00&count=500` — 500 ticks forward from that timestamp
- `?from=1700000000&count=-500` — 500 ticks ending at anchor
- `?from=2024_01_15_09_00_00&to=2024_01_15_10_00_00` — every tick in that 1-hour window
Tick-density caveat: liquid pairs (EURUSD in NY hours) emit 10–100 ticks/sec. A 1-hour `from+to` window can return millions of rows. Prefer `count` mode unless you really need every tick in a window — and even then, keep the window small or paginate.
Responses are gzip-compressed when the client sends `Accept-Encoding: gzip` — typically a 5–10× bandwidth reduction for large rate/tick fetches. `curl` honors this if you pass `--compressed`.
### Positions
| Method | Endpoint | Description |
| ------ | -------------------- | -------------------------------- |
| GET | `/positions` | List open positions (`?symbol=`) |
| GET | `/positions/:ticket` | Get position |
| PUT | `/positions/:ticket` | Update SL/TP |
| DELETE | `/positions/:ticket` | Close position |
**GET `/positions`** — array of position objects:
```json
{
"ticket": 42094820,
"time": 1771150554,
"time_msc": 1771150554509,
"time_update": 1771150554,
"time_update_msc": 1771150554509,
"type": 0,
"magic": 0,
"identifier": 42094820,
"reason": 3,
"volume": 100.0,
"price_open": 0.3005,
"sl": 0.28,
"tp": 0.32,
"price_current": 0.3003,
"swap": 0.0,
"profit": -0.02,
"symbol": "ADAUSD",
"comment": "",
"external_id": ""
}
```
`type` 0 = buy, 1 = sell. `profit` is unrealized P&L.
**PUT `/positions/:ticket`** — move your stop loss / take profit:
```json
{
"sl": 0.27,
"tp": 0.36
}
```
**DELETE `/positions/:ticket`** — close that shit:
```json
{
"volume": 500,
"deviation": 20
}
```
All fields optional. `volume` defaults to full position, `deviation` defaults to 20.
### Orders
| Method | Endpoint | Description |
| ------ | ----------------- | -------------------------------- |
| GET | `/orders` | List pending orders (`?symbol=`) |
| POST | `/orders` | Place an order |
| GET | `/orders/:ticket` | Get order |
| PUT | `/orders/:ticket` | Modify order |
| DELETE | `/orders/:ticket` | Cancel order |
**GET `/orders`** — array of pending order objects:
```json
{
"ticket": 42094812,
"time_setup": 1771147800,
"time_setup_msc": 1771147800123,
"time_done": 0,
"time_done_msc": 0,
"time_expiration": 0,
"type": 2,
"type_time": 0,
"type_filling": 1,
"state": 1,
"magic": 0,
"position_id": 0,
"position_by_id": 0,
"reason": 3,
"volume_initial": 1000.0,
"volume_current": 1000.0,
"price_open": 0.28,
"sl": 0.25,
"tp": 0.35,
"price_current": 0.2989,
"price_stoplimit": 0.0,
"symbol": "ADAUSD",
"comment": "",
"external_id": ""
}
```
`type`: 0=BUY, 1=SELL, 2=BUY_LIMIT, 3=SELL_LIMIT, 4=BUY_STOP, 5=SELL_STOP. `state`: 1=placed, 2=canceled, 3=partial, 4=filled, 5=rejected, 6=expired.
**POST `/orders`** — send it:
```json
{
"symbol": "ADAUSD",
"type": "BUY",
"volume": 1000,
"price": 0.28,
"sl": 0.25,
"tp": 0.35,
"deviation": 20,
"magic": 0,
"comment": "",
"type_filling": "IOC",
"type_time": "GTC"
}
```
Required: `symbol`, `type`, `volume`. Everything else is optional. `price` gets auto-filled for market orders.
Order types:
- Market: `BUY`, `SELL`
- Pending: `BUY_LIMIT`, `SELL_LIMIT`, `BUY_STOP`, `SELL_STOP`, `BUY_STOP_LIMIT`, `SELL_STOP_LIMIT`
Fill policies: `FOK`, `IOC` (default), `RETURN`
Expiration types: `GTC` (default), `DAY`, `SPECIFIED`, `SPECIFIED_DAY`
**PUT `/orders/:ticket`** — change your mind on a pending order:
```json
{
"price": 0.29,
"sl": 0.26,
"tp": 0.36,
"type_time": "GTC"
}
```
All fields optional.
### Trade Result
What comes back from POST/PUT/DELETE on orders and positions:
```json
{
"retcode": 10009,
"deal": 40536203,
"order": 42094820,
"volume": 100.0,
"price": 0.3005,
"bid": 0.3002,
"ask": 0.3005,
"comment": "Request executed",
"request_id": 1549268253,
"retcode_external": 0
}
```
`retcode` 10009 = you're good. Anything else = something went wrong.
### History
| Method | Endpoint | Description |
| ------ | ----------------- | -------------------------------- |
| GET | `/history/orders` | Order history (`?from=TS&to=TS`) |
| GET | `/history/deals` | Deal history (`?from=TS&to=TS`) |
`from` and `to` are required, unix epoch seconds.
**History order object** (completed/cancelled orders):
```json
{
"ticket": 42094820,
"time_setup": 1771150554,
"time_setup_msc": 1771150554509,
"time_done": 1771150554,
"time_done_msc": 1771150554509,
"time_expiration": 0,
"type": 0,
"type_time": 0,
"type_filling": 1,
"state": 4,
"magic": 0,
"position_id": 42094820,
"position_by_id": 0,
"reason": 3,
"volume_initial": 100.0,
"volume_current": 0.0,
"price_open": 0.3005,
"sl": 0.28,
"tp": 0.32,
"price_current": 0.3005,
"price_stoplimit": 0.0,
"symbol": "ADAUSD",
"comment": "Request executed",
"external_id": ""
}
```
`state` 4 = filled, 2 = canceled, 5 = rejected, 6 = expired. `volume_current` 0 = fully filled.
**Deal object** (actual executed trades):
```json
{
"ticket": 40536203,
"order": 42094820,
"time": 1771150554,
"time_msc": 1771150554509,
"type": 0,
"entry": 0,
"position_id": 42094820,
"symbol": "ADAUSD",
"volume": 100.0,
"price": 0.3005,
"commission": 0.0,
"swap": 0.0,
"profit": 0.0,
"fee": 0.0,
"magic": 0,
"reason": 3,
"comment": "",
"external_id": ""
}
```
`type`: 0 = buy, 1 = sell. `entry`: 0 = opening, 1 = closing. `profit` is 0 for entries, actual realized P&L for exits.
### Backtest
Run MT5 Strategy Tester backtests and optimizations over the HTTP API. These
endpoints are served by every terminal, but they only **run** successfully on a terminal whose
config.yaml entry has `mode: backtest`. The reason is structural: MT5 is
single-instance per portable data directory, so if `terminal64.exe` is already
running to back the live SDK, a Strategy Tester subprocess against the same
directory exits silently with code `0` and produces no report. `mode: backtest`
skips the auto-launch and the live-mode SDK init, leaving the data dir free
for the tester. Pick the broker/account namespace whose credentials you want
injected into the run's `[Common]` section — e.g. a dedicated
`darwinex/tester` entry next to your live `darwinex/main`.
If your broker uses suffixed symbols like `EURUSDp` or `EURUSD.p`, set
`terminals[].symbol_suffix` on that backtest terminal. If the broker uses plain
symbols, set `symbol_suffix: ""` explicitly.
Two-stage flow:
1. `POST /backtest/build-ini` — turns a small JSON spec into a fully formed
`tester.ini` (no credentials, no expert path resolution). Stateless helper;
you can also write the INI yourself.
2. `POST /backtest` — multipart upload of the INI plus the `.ex5` expert and
optional `.set` parameter file. Returns a `jobId`; poll for status; fetch
the report and terminal log when complete.
For a plain backtest, leave `[Tester].Optimization=0` or omit it. For an
optimization, set `[Tester].Optimization` to one of:
- `1` — slow complete algorithm
- `2` — fast genetic algorithm
- `3` — all symbols selected in Market Watch
Optimization runs require a `.set` file whose input parameters already contain
optimization ranges. mt5-httpapi can now either stage an MT5-saved `.set`
directly or generate one from structured JSON via `POST /backtest/build-set`.
Optimization modes do not all emit the same MT5 artifacts:
| Mode | MT5 setting | Search scope | Primary parsed artifact | Report name written by MT5 |
| ---- | ----------- | ------------ | ----------------------- | -------------------------- |
| `1` | slow complete | Single symbol in `[Tester].Symbol` | MT5 XML spreadsheet report | `<report>.xml` |
| `2` | genetic | Single symbol in `[Tester].Symbol` | MT5 XML spreadsheet report | `<report>.xml` |
| `3` | all Market Watch symbols | Symbols currently selected in Market Watch | `Tester/cache/*.opt` cache file | `<report>.symbols.xml` |
Mode `3` is the odd one out. MT5 still writes a report file, but it is the
header-only `.symbols.xml` variant and the actual pass rows live in the tester
cache. mt5-httpapi parses that cache, recovers pass-to-symbol mappings from the
agent logs, and exposes the discovered cache artifact in `optimizationCache`.
If you want the full mode-by-mode request/response examples, see
[`docs/backtest-optimization.md`](docs/backtest-optimization.md).
MT5 `.set` files are plain parameter files, typically UTF-16 text. A normal
saved set looks like this:
```ini
Properties_=------
Magic_Number=1615044595
Entry_Amount=0.01
Stop_Loss=0
Take_Profit=92
___0______=------
Ind0Param0=3
Ind0Param1=1
Ind0Param2=1
Ind0Param3=8.0
___1______=------
Ind1Param0=20
Ind1Param1=31
Ind1Param2=5
```
That example matches the structure of the bundled preset under `assets/sets/`:
simple `name=value` lines plus separator keys.
For optimization, MT5 UI exports each optimizable input in this form:
```ini
property=value||start||step||stop||Y|N
```
Meaning:
| Position | Meaning | Notes |
| -------- | ------- | ----- |
| 1 | Current value | Default or starting value |
| 2 | Start | Optimization range start |
| 3 | Step | Increment per pass |
| 4 | Stop | Optimization range end |
| 5 | Optimize | `Y` = enabled, `N` = disabled |
Examples:
```ini
TakeProfit=50||10||5||100||Y
StopLoss=30||10||5||80||Y
LotSize=0.1||0||0||0||N
```
- `TakeProfit` and `StopLoss` are optimized because the `optimize` field is `Y`.
- `LotSize` stays fixed because the `optimize` field is `N`.
So a parameter you want optimized with a known range looks like this:
```ini
MyParam=50||10||5||200||Y
```
And a parameter you want fixed looks like this:
```ini
MyParam=50||0||0||0||N
```
Create the set file from the MT5 Strategy Tester "Inputs" tab after enabling
optimization ranges for the parameters you want to vary, then save it.
If you already have structured parameter metadata, `POST /backtest/build-set`
accepts JSON and returns MT5-native `.set` text using the same `Y` / `N`
markers.
Example JSON for `POST /backtest/build-set`:
```json
{
"comments": [
"saved on 2026.05.15 08:30:02",
"this file contains input parameters for testing/optimizing MyEA"
],
"parameters": [
{"name": "_Properties_", "value": "------"},
{
"name": "Take_Profit",
"value": 92,
"start": 80,
"step": 4,
"stop": 92,
"optimize": true
},
{
"name": "Stop_Loss",
"value": 0,
"start": 0,
"step": 1,
"stop": 10,
"optimize": false
}
]
}
```
The response is `text/plain` `.set` content ready to save or upload.
Only one tester runs at a time per API process (serialized by an internal lock);
additional submissions queue.
#### Asset sources
The expert and set file can be sent inline (preferred for ad-hoc runs) or
referenced by name from a host-managed pool:
```
assets/
experts/ # *.ex5 — host-managed expert advisors (mounted read-only)
sets/ # *.set — host-managed parameter files
```
The `docker-compose.yml` mount `./assets:/shared/assets:ro` exposes them inside
the VM so the API can read them. Path traversal in `expert_name` / `set_name`
is rejected.
The repo also ships a dedicated warm-up pair:
```text
assets/experts/MT5SystemWarmup.mq5
assets/experts/MT5SystemWarmup.ex5
```
`MT5SystemWarmup.ex5` is the hard default expert used by the backtest-manager's
historical warm-up flow. The `.mq5` file exists so you can regenerate the
compiled `.ex5` inside the Windows VM when needed.
#### Regenerating `MT5SystemWarmup.ex5`
When the mt5-httpapi VM is running and `run.sh` has synced scripts into
`data/shared/scripts/`, regenerate the compiled warm-up EA from inside the
Windows guest:
1. Open the VM over noVNC.
2. Launch `cmd.exe` inside the guest.
3. Run:
```bat
C:\Users\Docker\Desktop\Shared\scripts\compile-warmup-ea.bat
```
What the script does:
- locates the first installed broker `base` terminal under
`C:\Users\Docker\Desktop\Shared\terminals\*\base`
- copies `C:\Users\Docker\Desktop\Assets\experts\MT5SystemWarmup.mq5`
into that terminal's `MQL5\Experts\Advisors\`
- runs `MetaEditor64.exe /compile:... /log:...`
- copies the resulting `MT5SystemWarmup.ex5` back into
`C:\Users\Docker\Desktop\Assets\experts\`
Compile log lands at:
```text
C:\Users\Docker\Desktop\Shared\logs\compile-warmup-ea.log
```
If the `Assets` folder is not exposed at `C:\Users\Docker\Desktop\Assets`,
the script falls back to `C:\Users\Docker\Desktop\Shared\assets`.
#### `POST /backtest/build-ini`
Body (JSON):
| Field | Required | Notes |
| ------------------ | -------- | -------------------------------------------------- |
| `symbol` | yes | e.g. `NZDJPY` |
| `timeframe` | yes | `M1` `M5` `M15` `H1` `D1` … (21 standard values) |
| `expert` | yes | filename ending in `.ex5` |
| `fromDate`+`toDate`| one of | `YYYY-MM-DD` |
| `lastYears` | one of | integer; window ends today UTC |
| `lastDays` | one of | integer |
| `modelling` | no | `every-tick` `1m-ohlc` `open-prices` `real-ticks` |
| `latencyMs` | no | integer milliseconds → `ExecutionMode` |
| `deposit` | no | default `10000` |
| `currency` | no | default `USD` |
| `leverage` | no | default `100`, written as `1:N` |
| `expertParameters` | no | `.set` filename |
| `optimization` | no | `0` off, `1` slow complete, `2` genetic, `3` Market Watch symbols |
| `optimizationCriterion` | no | `0..7`; default `0` (max balance) |
| `reportName` | no | default `backtest-report.htm` for backtests, `optimization-report.xml` for optimizations |
Returns `text/plain` with the generated INI.
Example JSON for an optimization INI:
```json
{
"symbol": "GBPUSD",
"timeframe": "M15",
"expert": "MyEA.ex5",
"lastYears": 3,
"modelling": "open-prices",
"expertParameters": "myea-optimizer.set",
"optimization": 2,
"optimizationCriterion": 5,
"reportName": "gbpusd-m15-sharpe-search"
}
```
#### `POST /backtest`
Multipart form fields:
| Field | Required | Notes |
| -------------- | -------- | -------------------------------------------------------------- |
| `ini` | yes | INI file (file upload) |
| `expert` | one of | `.ex5` upload |
| `expert_name` | one of | filename in `assets/experts/` |
| `set` | no | `.set` upload |
| `set_name` | no | filename in `assets/sets/` |
| `topPasses` | no | For optimization jobs, keep the top `1..500` parsed XML passes in the status payload. Default `50`. |
| `timeout` | no | Duration string override (`"30m"`, `"6h"`, `"3h30m"`). Defaults to `backtest_timeout` from `config.yaml`, then hardcoded `6h`. |
Responds `202 Accepted` with `Retry-After` header and the queued job payload:
```json
{
"jobId": "b3f7…",
"status": "queued",
"broker": "darwinex",
"account": "live",
"submittedAt": "2026-05-12T10:00:00Z",
"statusUrl": "/backtest/b3f7…",
"reportUrl": "/backtest/b3f7…/report",
"logUrl": "/backtest/b3f7…/log",
"pollAfterSeconds": 60,
"optimizationType": 0,
"optimizationResults": null,
"optimizationCache": null,
"queuePosition": 1
}
```
`[Common]` `Login` / `Password` / `Server` in the uploaded INI are always
overwritten with the credentials from `config.yaml` for the request's
broker/account. The expert path is rewritten to `Uploaded\<basename>` and the
set file is namespaced per job to avoid collisions.
#### `GET /backtest/<jobId>`
Status payload. `status` ∈ `queued` `running` `completed` `failed`. When
completed, includes a `summary` object parsed from the HTML report
(`netProfit`, `profitFactor`, `recoveryFactor`, `expectedPayoff`, `sharpeRatio`,
`maxDrawdown`, `totalTrades`, `profitTrades`, `lossTrades`, …).
For optimization jobs, the payload instead includes:
- `optimizationType` — the submitted MT5 optimization mode (`1`, `2`, or `3`)
- `optimizationResults` — a parsed top-N list sorted by `Result` descending
- `optimizationCache` — cache artifact metadata when results came from an MT5 `.opt` cache file
Result source depends on the submitted mode:
- Modes `1` and `2` parse the MT5 XML spreadsheet report first-class, and only use cache parsing if an `.opt` cache is available for the same job.
- Mode `3` parses the MT5 tester cache first-class because the `.symbols.xml` report does not contain the optimization rows.
The API keeps the MT5 column names as-is. If the XML export includes columns
such as `Profit`, `Profit Factor`, `Expected Payoff`, `Drawdown`,
`Recovery Factor`, `Sharpe Ratio`, or optimized input names, those same fields
appear in each `optimizationResults` row.
Example optimization status payload:
```json
{
"jobId": "8c2a…",
"status": "completed",
"broker": "darwinex",
"account": "tester",
"reportName": "gbpusd-m15-sharpe-search.xml",
"reportUrl": "/backtest/8c2a…/report",
"logUrl": "/backtest/8c2a…/log",
"optimizationType": 2,
"optimizationCache": null,
"optimizationResults": [
{
"Pass": 184,
"Result": 2.41,
"Profit": 1263.5,
"Profit Factor": 1.48,
"Expected Payoff": 13.02,
"Recovery Factor": 3.11,
"Total trades": 97,
"Sharpe Ratio": 2.41,
"FastPeriod": 12,
"SlowPeriod": 34
}
]
}
```
Example mode-3 optimization payload:
```json
{
"jobId": "b05643…",
"status": "completed",
"broker": "darwinex",
"account": "live",
"reportName": "mode3-gbpcad-m15-last5y-rerun5.symbols.xml",
"reportUrl": "/backtest/b05643…/report",
"logUrl": "/backtest/b05643…/log",
"optimizationType": 3,
"optimizationCache": {
"name": "EA Studio GBPCAD M15 1615044595.all_symbols.M15.20210525.20260525.22.788ECDD113BA3097A58EF888EBEFF9CA.opt",
"pattern": "EA Studio GBPCAD M15 1615044595.all_symbols.M15.20210525.20260525.*.opt",
"build": "22",
"cacheHash": "788ECDD113BA3097A58EF888EBEFF9CA",
"rowCount": 28,
"symbolComponent": "all_symbols",
"period": "M15"
},
"optimizationResults": [
{
"Pass": 21,
"Symbol": "GBPJPY",
"Result": 1657.54,
"Profit": 657.54,
"Profit Factor": 1.9,
"Expected Payoff": 2.57,
"Recovery Factor": 3.89,
"Sharpe Ratio": 0.75,
"Equity DD %": 11.22,
"Trades": 256,
"Custom": ""
}
]
}
```
#### `GET /backtest/<jobId>/report` & `/log`
Stream the raw report and terminal log file. Backtests return the MT5 HTML
report. Optimizations return the MT5 XML spreadsheet export. `404` until the
job finishes.
#### Worked example
```bash
export URL=http://127.0.0.1:8888/darwinex/live
export TOK=changeme-mt5-httpapi-token
# 1. Build INI for a 5-year NZDJPY M15 open-prices run with 5 ms latency.
curl -sS -X POST "$URL/backtest/build-ini" \
-H "Authorization: Bearer $TOK" -H "Content-Type: application/json" \
-d '{"symbol":"NZDJPY","timeframe":"M15","expert":"EA Studio NZDJPY M15 1615044595.ex5","lastYears":5,"modelling":"open-prices","latencyMs":5,"expertParameters":"ea studio nzdjpy m15 1615044595.set"}' \
> tester.ini
# 2. Submit using a host-managed expert + set already sitting in assets/.
JOB=$(curl -sS -X POST "$URL/backtest" \
-H "Authorization: Bearer $TOK" \
-F "ini=@tester.ini" \
-F "expert_name=EA Studio NZDJPY M15 1615044595.ex5" \
-F "set_name=ea studio nzdjpy m15 1615044595.set" \
| jq -r .jobId)
# 3. Poll until done.
while :; do
STATUS=$(curl -sS -H "Authorization: Bearer $TOK" "$URL/backtest/$JOB" | jq -r .status)
echo "$STATUS"; [[ "$STATUS" == completed || "$STATUS" == failed ]] && break
sleep 30
done
# 4. Fetch the report.
curl -sS -H "Authorization: Bearer $TOK" "$URL/backtest/$JOB/report" -o report.htm
```
#### Optimization example
This assumes you already created a `.set` file in MT5 with optimization ranges
enabled and placed it in `assets/sets/` or plan to upload it inline.
```bash
export URL=http://127.0.0.1:8888/darwinex/tester
export TOK=changeme-mt5-httpapi-token
# Build the INI, submit the multipart job, poll to completion, then print both
# the parsed API summary and the first rows from the raw MT5 XML report.
tmp_ini=$(mktemp) && \
job_json=$(mktemp) && \
trap 'rm -f "$tmp_ini" "$job_json"' EXIT && \
curl -sS -X POST "$URL/backtest/build-ini" \
-H "Authorization: Bearer $TOK" \
-H "Content-Type: application/json" \
-d '{"symbol":"GBPCAD","timeframe":"M15","expert":"EA Studio GBPCAD M15 1615044595.ex5","lastYears":1,"modelling":"open-prices","expertParameters":"ea studio gbpcad m15 1615044595.take-profit-opt-80-92-step4.set","optimization":1,"optimizationCriterion":0,"reportName":"gbpcad-m15-last1y-openprices-opt"}' \
> "$tmp_ini" && \
curl -sS -X POST "$URL/backtest" \
-H "Authorization: Bearer $TOK" \
-F "ini=@$tmp_ini;filename=tester.ini" \
-F "expert_name=EA Studio GBPCAD M15 1615044595.ex5" \
-F "set_name=ea studio gbpcad m15 1615044595.take-profit-opt-80-92-step4.set" \
-F "topPasses=20" \
> "$job_json" && \
JOB=$(jq -r '.jobId' "$job_json") && \
echo "Submitted job: $JOB" && \
while :; do \
STATUS_JSON=$(curl -sS -H "Authorization: Bearer $TOK" "$URL/backtest/$JOB") && \
STATUS=$(printf '%s' "$STATUS_JSON" | jq -r '.status') && \
echo "Status: $STATUS" && \
[[ "$STATUS" == completed || "$STATUS" == failed ]] && break; \
sleep 10; \
done && \
echo && echo "Final API summary:" && \
printf '%s\n' "$STATUS_JSON" | jq '{jobId,status,exitCode,durationSeconds,optimizationResults}' && \
echo && echo "Report preview:" && \
curl -sS -H "Authorization: Bearer $TOK" "$URL/backtest/$JOB/report" \
| grep -E '(<Row>|<Cell><Data ss:Type="String">|<Cell><Data ss:Type="Number">|<Cell ss:StyleID="[^"]+"><Data ss:Type="Number">)' \
| head -n 60
```
Notes:
- Use a terminal configured with `mode: backtest`, not a live terminal namespace.
- Optimization results depend on the ranges encoded in the `.set` file. If no ranges are enabled in MT5, optimization is not meaningful.
- `optimizationResults` is a convenience summary. For modes `1` and `2`, the raw XML at `/report` remains the full source of truth. For mode `3`, the parsed `.opt` cache plus `optimizationCache` metadata are the best debugging source because `/report` is the MT5 `.symbols.xml` header export.
- If a metric you expect is missing from `optimizationResults`, first check the raw XML report. The API preserves MT5's exported columns rather than remapping them to a fixed schema.
## MCP Interface
Every terminal also mounts a [Model Context Protocol](https://modelcontextprotocol.io) server (streamable-HTTP) at `/mcp`, alongside the REST API, in the same process. It exposes **dedicated, typed tools** grouped by family — each tool's name, typed params, and description are what the agent reads (no guessing at raw paths). Every tool runs the exact same handler, auth, and MT5 locking as a real HTTP request.
- **Market data** — `list_symbols`, `get_symbol`, `get_tick`, `get_rates(symbol, timeframe, count?)`, `get_ticks`, `get_rates_ta`
- **Account / positions** — `get_account`, `list_positions`, `get_position`, `modify_position`, `close_position`
- **Orders** — `list_orders`, `get_order`, `create_order(symbol, type, volume, price?, sl?, tp?)`, `modify_order`, `cancel_order`
- **History / terminal / backtest** — `get_history_orders`, `get_history_deals`, `get_terminal`, `terminal_control`, `get_backtest`, `ping`
- **Escape hatch** — `request(method, path, query?, body?)` + `endpoints` (route catalog) for anything without a dedicated tool
The order/position tools (`create_order`, `cancel_order`, `close_position`, …) are irreversible live-account actions and say so in their tool descriptions.
Same bearer auth as REST: an empty `api_token` disables auth on `/mcp` too; a configured token requires `Authorization: Bearer <token>` on every MCP call.
The reachable URL is the terminal's normal base plus `/mcp/` — nginx strips `/<broker>/<account>/` and proxies the rest straight through, so:
```
$MT5_API_URL/mcp/
# e.g. http://localhost:8888/roboforex/main/mcp/
```
```bash
# Raw JSON-RPC — call the request tool directly
curl -sS -H "Authorization: Bearer $MT5_API_TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
"$MT5_API_URL/mcp/" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ping","arguments":{}}}'
```
Order/position mutations reached through `request` are the same live, irreversible trading actions as calling those REST routes directly — confirm parameters before invoking them.
For MCP clients that only speak local stdio servers, the [`@psyb0t/mt5-httpapi`](.agents/plugins/mt5-httpapi) OpenClaw plugin is a thin stdio↔HTTP bridge to this endpoint.
## Optimization Guide
See [`docs/backtest-optimization.md`](docs/backtest-optimization.md) for a dedicated guide covering:
- how modes `1`, `2`, and `3` differ operationally
- how to build INIs and `.set` files for each mode
- complete `curl` examples for all optimization modes
- how to interpret `optimizationResults`, `optimizationCache`, and the generated report artifacts
If the API is restarted while a backtest is running, the orphaned job is marked
`failed` (`API restarted before completion`) on the next startup.
## Examples
```bash
export MT5_API_URL=http://localhost:8888/roboforex/main
export MT5_API_TOKEN=$(grep ^api_token config/config.yaml | awk -F'"' '{print $2}') # omit if no auth configured
# Check your balance
curl -H "Authorization: Bearer $MT5_API_TOKEN" $MT5_API_URL/account
# Grab some EURUSD H4 candles
curl -H "Authorization: Bearer $MT5_API_TOKEN" "$MT5_API_URL/symbols/EURUSD/rates?timeframe=H4&count=100"
# YOLO 1000 ADAUSD with SL and TP
curl -X POST -H "Authorization: Bearer $MT5_API_TOKEN" $MT5_API_URL/orders \
-H "Content-Type: application/json" \
-d '{"symbol": "ADAUSD", "type": "BUY", "volume": 1000, "sl": 0.25, "tp": 0.35}'
# Place a pending buy limit
curl -X POST -H "Authorization: Bearer $MT5_API_TOKEN" $MT5_API_URL/orders \
-H "Content-Type: application/json" \
-d '{"symbol": "ADAUSD", "type": "BUY_LIMIT", "volume": 1000, "price": 0.28, "sl": 0.25, "tp": 0.35}'
# Move your SL and TP
curl -X PUT -H "Authorization: Bearer $MT5_API_TOKEN" $MT5_API_URL/positions/12345 \
-H "Content-Type: application/json" \
-d '{"sl": 0.27, "tp": 0.36}'
# Close half
curl -X DELETE -H "Authorization: Bearer $MT5_API_TOKEN" $MT5_API_URL/positions/12345 \
-H "Content-Type: application/json" \
-d '{"volume": 500}'
# Close everything
curl -X DELETE -H "Authorization: Bearer $MT5_API_TOKEN" $MT5_API_URL/positions/12345
# Hit different terminals when running multi-terminal
curl -H "Authorization: Bearer $MT5_API_TOKEN" http://localhost:8888/roboforex/main/account
curl -H "Authorization: Bearer $MT5_API_TOKEN" http://localhost:8888/ftmo/challenge1/account
# Get deal history for the last 24h
curl -H "Authorization: Bearer $MT5_API_TOKEN" "$MT5_API_URL/history/deals?from=$(date -d '1 day ago' +%s)&to=$(date +%s)"
```
## Go Client
A typed Go client lives in [`clients/go/`](clients/go/). All endpoints are covered, errors map to typed sentinels, and structs have been verified against live responses.
```bash
go get github.com/psyb0t/mt5-httpapi/clients/go
```
```go
package main
import (
"context"
"errors"
"log"
"os"
"time"
mt5 "github.com/psyb0t/mt5-httpapi/clients/go"
"github.com/psyb0t/aichteeteapee"
)
func main() {
c, err := mt5.New(mt5.Config{
BaseURL: os.Getenv("MT5_API_URL"),
Token: os.Getenv("MT5_API_TOKEN"), // empty string if server has no auth
Timeout: 30 * time.Second,
})
if err != nil {
log.Fatal(err)
}
ctx := context.Background()
acc, err := c.GetAccount(ctx)
if errors.Is(err, mt5.ErrNotInitialized) {
log.Fatal("MT5 still booting, retry in a sec")
}
if errors.Is(err, aichteeteapee.ErrUnauthorized) {
log.Fatal("bad token")
}
if err != nil {
log.Fatal(err)
}
log.Printf("balance=%.2f %s leverage=1:%d", acc.Balance, acc.Currency, acc.Leverage)
// Place a market buy
res, err := c.CreateOrder(ctx, &mt5.CreateOrderRequest{
Symbol: "EURUSD",
Type: "BUY",
Volume: 0.1,
SL: 1.08,
TP: 1.10,
})
if err != nil {
log.Fatal(err)
}
log.Printf("retcode=%d order=%d deal=%d price=%.5f", res.Retcode, res.Order, res.Deal, res.Price)
// Pull H4 candles
rates, err := c.GetRates(ctx, "EURUSD", mt5.RatesQuery{Timeframe: "H4", Count: 100})
if err != nil {
log.Fatal(err)
}
log.Printf("got %d candles, last close=%.5f", len(rates), rates[len(rates)-1].Close)
}
```
### Available methods
| Method | Endpoint |
| --- | --- |
| `Ping` | `GET /ping` |
| `LastError` | `GET /error` |
| `GetTerminal` / `InitTerminal` / `ShutdownTerminal` / `RestartTerminal` | `/terminal[/...]` |
| `GetAccount` | `GET /account` |
| `ListSymbols` / `GetSymbol` / `GetTick` / `GetRates` / `GetRatesTA` / `GetTicks` | `/symbols[/...]` |
| `ListOrders` / `CreateOrder` / `GetOrder` / `UpdateOrder` / `CancelOrder` | `/orders[/...]` |
| `ListPositions` / `GetPosition` / `UpdatePosition` / `ClosePosition` | `/positions[/...]` |
| `HistoryOrders` / `HistoryDeals` | `/history/...` |
### Error mapping
HTTP status maps to typed errors you can `errors.Is()` against:
| Status | Error |
| --- | --- |
| 400 | `aichteeteapee.ErrBadRequest` |
| 401 | `aichteeteapee.ErrUnauthorized` |
| 403 | `aichteeteapee.ErrForbidden` |
| 404 | `aichteeteapee.ErrNotFound` |
| 409 | `aichteeteapee.ErrConflict` |
| 422 | `aichteeteapee.ErrUnprocessableEntity` |
| 429 | `aichteeteapee.ErrTooManyRequests` |
| 500 | `aichteeteapee.ErrInternalServer` |
| 502 | `aichteeteapee.ErrBadGateway` |
| 503 | `mt5httpapi.ErrNotInitialized` (MT5 still booting) |
| 504 | `aichteeteapee.ErrGatewayTimeout` |
Helper: `mt5httpapi.IsNotInitialized(err)` shortcuts the common 503 retry case.
## Technical Analysis
Two options:
**Server-side via the wickworks sidecar (`POST /symbols/:symbol/rates/ta`)** — bars come out already enriched with indicators (RSI, MACD, Bollinger Bands, ADX, VWAP, Ichimoku, Order Blocks / FVGs / BOS / CHoCH, swing structure, S/R levels, dozens more). Primitives only — wickworks emits raw indicator series and SMC structural facts; interpretive signals (divergences, crossover events, etc.) belong in your consumer. The wickworks container ships with the docker-compose and is locked to the mt5 net namespace — no external traffic, no separate deploy. See the [endpoint docs](#symbols) and the full indicator catalog with params + output shapes at [github.com/psyb0t/docker-wickworks](https://github.com/psyb0t/docker-wickworks).
**Client-side** — grab the raw candles with `GET /rates` and crunch them yourself. There's a full working example in `examples/python/` using [pandas-ta](https://github.com/twopirllc/pandas-ta) with ATR, RSI, MACD, Bollinger Bands, MFI, Stochastic, ADX, VWAP, and moving averages.
```bash
cd examples/python
pip install -r requirements.txt
# Default: EURUSD H4 200 candles
python ta.py
# Custom symbol/timeframe/count
python ta.py BTCUSD H1 100
python ta.py ADAUSD D1 200
# Custom API URL
MT5_API_URL=http://10.0.0.5:8888/roboforex/main python ta.py EURUSD D1
# Candlestick chart with TA overlays (1920x1080 PNG)
python chart.py ADAUSD
python chart.py BTCUSD H1 100
python chart.py EURUSD D1 200 -o eurusd.png
```
Check out `indicators.py` for the individual indicator functions and `signals.py` for signal detection. Use them as building blocks for your own shit.
## Make Targets
```
make up Fire up the VM (downloads ISO if needed)
make down Shut it down
make logs Tail the logs
make status Check VM and API status
make lint Lint every .ps1/.sh script in a throwaway Docker image
make format Apply shfmt formatting to the .sh files in place
make test Run unit tests in a throwaway Docker image
make clean Nuke VM disk and state (keeps ISO)
make distclean Nuke everything including ISO
```
`make lint` covers the scripts that run inside the VM as well as the host-side
shell: `.ps1` gets a pure-ASCII check, a parse check, and PSScriptAnalyzer;
`.sh` gets shellcheck and shfmt. The ASCII rule is not cosmetic — Windows
PowerShell 5.1 reads `.ps1` as ANSI unless the file has a UTF-8 BOM, so a
multi-byte character inside a string literal gets mangled into a parse error you
won't see until the VM boots. `make format` fixes whatever shfmt flags.
## Ports
| Port | Service | Override |
| ----- | ----------------------- | --------------------------------- |
| 8006 | noVNC (VM desktop) | `NOVNC_PORT=9006 make up` |
| 8888 | HTTP API (nginx, all terminals) | `API_HOST_PORT=9999 make up` |
Only two ports leave the docker network. Per-terminal ports from `config.yaml`'s `terminals:` list stay container-internal — nginx (always-on, generated from the same list) routes `/<broker>/<account>/...` to the right terminal via docker DNS, and the mt5 container's iptables DNAT forwards from there into the Windows VM. The host bind is loopback-only (`127.0.0.1:8888`) by default; change the bind in `docker-compose.yml` if you want LAN exposure, or use the Tailscale sidecar below for tailnet exposure.
## Tailscale (optional)
Expose the API over your tailnet using a bare MagicDNS hostname — `http://mt5-httpapi/<broker>/<account>/...` — works with both stock Tailscale and self-hosted Headscale. Plain HTTP (no TLS) by design: bare hostnames don't have matching certs, and the wireguard layer already encrypts everything inside the tailnet.
How it works: a `tailscale` sidecar joins the tailnet in its **own netns** (bridge mode, not host net) so it gets its own tailnet identity — ACLs scope to the sidecar's node only, and the host's tailscale (if any) stays out of the sidecar's inbound path. Tailscale Serve listens on port 80 inside that netns and proxies to the always-on `nginx` sidecar (`http://nginx:80`) over docker's internal network. nginx then strips `/<broker>/<account>/` and proxies to the right terminal via docker DNS. `nginx.conf` is auto-generated from `config.yaml`'s `terminals:` list on every `make up`; the Tailscale Serve config is wired in via the `tailscale serve` CLI from inside the sidecar (it needs the live FQDN, which only the CLI knows) and persisted in tailscaled state.
The sidecar runs in TUN mode (`TS_USERSPACE=false`) so a real `tailscale0` interface exists inside its netns. That means any outbound to `100.64.0.0/10` from the sidecar is routed via the sidecar's own tunnel under its tailnet identity — not via the host's `tailscale0` (if the host has one on a different account). Note the scope: this only applies to traffic originating in the sidecar's netns. The other containers (`nginx`, `mt5`) are on the docker bridge, not in the sidecar's netns, so any tailnet-bound traffic from them would still fall through to the host's tunnel. We don't initiate tailnet outbound from those containers in the default setup, so it doesn't bite — but if you add a service that does, put it on `network_mode: service:tailscale`.
**Setup**:
1. Set your auth key in `config/config.yaml`:
```yaml
tailscale:
auth_key: "tskey-auth-..."
login_server: "" # for Headscale, set "https://headscale.your.domain"
```
2. Uncomment the `tailscale` block in `docker-compose.yml`. (nginx is always on — no need to uncomment anything for it.)
3. `make up`. `run.sh` reads `config.yaml`, writes `TS_AUTHKEY` (and `TS_EXTRA_ARGS=--login-server=...` if Headscale) to `.env`, brings the stack up, waits for tailscaled to authenticate, and runs `tailscale serve --bg --http=80 http://nginx:80` inside the sidecar to wire the tailnet :80 listener to nginx. The Serve config persists in `.data/tailscale/state`, so subsequent `make up` calls don't need to redo it.
**State persistence**: tailnet identity lives in `.data/tailscale/state/`. `make down`/`make up` reuses the existing login — `TS_AUTHKEY` is consumed only on first auth (or after `rm -rf .data/tailscale/state`). Use a reusable auth key if you expect to wipe state.
**Multi-terminal URL scheme**:
```
http://mt5-httpapi/roboforex/main/account
http://mt5-httpapi/roboforex/main/symbols/EURUSD/rates?count=100
http://mt5-httpapi/ftmo/challenge1/positions
```
The API token (if set in `config.yaml`) still applies — Tailscale handles network-level access, the token handles application-level auth.
## Cloudflare Tunnel (optional)
Expose the API publicly without opening firewall ports. cloudflared dials out to Cloudflare's edge and proxies to the always-on `nginx` sidecar — one tunnel, one hostname, every terminal reachable behind `/<broker>/<account>/`.
**Setup**:
1. Install cloudflared on the host (one-off, only needed to create the tunnel):
```bash
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o /tmp/cloudflared
sudo install /tmp/cloudflared /usr/local/bin/cloudflared
```
2. Authenticate, create a tunnel, and route a single hostname to it (must be a zone you control on Cloudflare):
```bash
cloudflared tunnel login
cloudflared tunnel create mt5-httpapi
cloudflared tunnel route dns mt5-httpapi mt5-api.yourdomain.com
```
3. Drop the credentials and config into `.data/cloudflared/`:
```bash
mkdir -p .data/cloudflared
cp ~/.cloudflared/<tunnel-id>.json .data/cloudflared/creds.json
```
Create `.data/cloudflared/config.yml`:
```yaml
tunnel: <tunnel-id>
credentials-file: /etc/cloudflared/creds.json
ingress:
- hostname: mt5-api.yourdomain.com
service: http://nginx:80
- service: http_status:404
```
4. Uncomment the `cloudflared` block in `docker-compose.yml` and `make up`.
**Public URL scheme**:
```
https://mt5-api.yourdomain.com/roboforex/main/account
https://mt5-api.yourdomain.com/ftmo/challenge1/positions
```
Cloudflare terminates TLS at the edge — you get HTTPS for free without managing certs. The connection from cloudflared to `nginx:80` is plain HTTP over the docker bridge, but it never leaves the host.
**Subdomain depth**: Cloudflare's free Universal SSL covers `*.yourdomain.com` but not deeper levels like `*.mt5.yourdomain.com`. Use a single subdomain directly under the root domain.
The API token still applies on top — Cloudflare gates the public reachability, the bearer token gates the application. Treat the public hostname as hostile and **always set `api_token` in `config.yaml`** when using this.
## Project Structure
```
config/ Your config shit
config.yaml Single source of truth (gitignored)
config.yaml.example Committed template — copy to config.yaml
setup.bat Custom boot commands (optional)
hosts Extra entries for the VM's hosts file (optional)
scripts/ Scripts that run inside the Windows VM
oem-install.bat First-boot OEM script (creates startup entry)
install.bat Setup (Python, MT5, firewall) — runs every boot
start.bat Boot entrypoint (install + start terminals + APIs)
reboot.bat The only reboot path — writes rebooting.flag and
releases both lock dirs before shutting down
acquire_lock.ps1 Boot-stamped lock acquire for start.bat and
install.bat; auto-clears reboot-orphaned locks
debloat.bat Windows debloat script
defender-remover/ Windows Defender removal tool
mt5api/ Python HTTP API server
handlers/ Route handlers
config.py Configuration (--broker, --account, --port CLI args)
mt5client.py MT5 wrapper
server.py Flask routes
examples/ Usage examples
python/ TA, charting, and API client modules
mt5installers/ Broker MT5 setup executables (gitignored)
data/ Generated/volatile data (gitignored)
win.iso Windows ISO
storage/ VM disk
shared/ Shared folder with VM
scripts/ Bat scripts synced from scripts/
config/ Config synced from config/
terminals/ MT5 installs per broker/account
logs/ All log files
mt5api/ Python API package
oem/ First-boot scripts
```
## Concurrency & Backpressure
The MT5 Python SDK is single-connection-per-process and not threadsafe — concurrent calls into `mt5.*` corrupt internal state (notably `last_error`, which several flows read implicitly). The API enforces a process-wide mutex around all SDK calls, held for the **entire** duration of a request handler so multi-call handlers (`POST /orders`, the `get_rates` retry loop, etc.) are atomic against everything else.
Knock-on effects you'll observe:
- **`/ping`** is the only handler that doesn't take the lock. Use it for liveness probes — it stays responsive even when the SDK is wedged.
- Every `mt5.*` call has a hard 30s timeout. A wedged call returns **`504 mt5 call timed out`** and releases the lock. The orphaned C-thread is still spinning inside the SDK; the health monitor will detect a dead terminal and run `restart_terminal` to free it.
- When too many requests pile up on the lock, new ones get **`503 queue depth N exceeds max M`** instead of waiting. Default cap is 20; tune with `MT5_MAX_QUEUE_DEPTH=...` in the environment.
- Per-call timing logs (`<req_id> mt5.<fn> dur_ms=...`) are emitted for every SDK call so wedge investigations have data to chew on.
If you see persistent 503/504 from a single terminal, check `data/shared/logs/api-<broker>-<account>.log` for `mt5.* TIMEOUT` lines — that's the SDK call that wedged.
## Logs
Inside the VM's shared folder (`data/shared/logs/`):
- `install.log` - MT5 installation progress (install.bat)
- `start.log` - Boot sequence log (start.bat)
- `pip.log` - Python package installation
- `api-<broker>-<account>.log` - Per-terminal API logs
- `windows-events.log` - Tailed Windows System + Application event logs (Warning/Error/Critical level only). Catches OOM kills (`Microsoft-Windows-Resource-Exhaustion-Detector`), terminal64.exe crashes (`Application Error`), BSODs (`BugCheck`), service failures, etc.
- `full.log` - Single narrative of all of the above with `[start]` / `[install]` / `[api:<broker>/<account>]` / `[winevt]` tags. `tail -f full.log` is the one-stop diagnostic view.
### Rotation
A small alpine sidecar (`log-rotator`) rotates every `*.log` in `data/shared/logs/` daily and prunes archives older than 7 days. Naming: `full.log` → `full.log.YYYYMMDD` (yesterday's date) at the next post-midnight wakeup. Idempotent, hourly check loop, no cron daemon needed.
Override defaults via `docker-compose.yml`:
- `RETAIN_DAYS` (default `7`) - how many days of archives to keep
- `INTERVAL` (default `3600`) - how often to check for the day boundary, in seconds
Truncation is in-place (the archive is a copy, then the original is `:>`-truncated) so the Python API's open log handle keeps writing without reopening.
When shit breaks, check these first.
## License
WTFPL
Connection Info
You Might Also Like
No recommendations available