Backtesting without babysitting MT5
Build Strategy Tester files, launch the job, watch it run, and pull the reports back over HTTP instead of spending your life clicking around inside MT5.
Contents
- Backtest API
- Build a set file
- Asset sources
- Regenerate the warm-up expert
- Build an INI
- Submit a job
- Poll job status
- Download report and log
- Read live diagnostic tails
- Cancel a job
- Scheduled reboots
- Worked example
- Optimization example
- Optimization guide
- Multi-VM scaling
Backtest API
This is the whole Strategy Tester pipeline over HTTP: build the ugly MT5 files, submit them, poll the job, then grab the useful shit when it finishes. Responses below use snake_case field names. The old camelCase names still show up alongside them in responses, and request bodies still accept either one, but the camelCase names are deprecated and go away in v5.0.0. POST /backtest execution requires a terminal whose config.yaml entry has mode: backtest. The two stateless builders, POST /backtest/build-ini and POST /backtest/build-set, work in either mode because they only transform JSON into text and never launch MT5. The execution restriction 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.
| Method | Endpoint | Purpose |
|---|---|---|
| POST | /backtest/build-ini | Build a complete tester.ini from JSON. |
| POST | /backtest/build-set | Build an MT5 .set parameter file from JSON. |
| POST | /backtest | Submit an INI plus expert and optional set file. |
| GET | /backtest/<job_id> | Poll job state and parsed results. |
| GET | /backtest/<job_id>/report | Download the raw MT5 report. |
| GET | /backtest/<job_id>/log | Download the terminal log. |
| GET | /backtest/<job_id>/tail | Read live diagnostic tails while a job runs. |
| POST | /backtest/<job_id>/cancel | Stop a queued or running job and mark it cancelled. |
For a plain backtest, leave [Tester].Optimization=0 or omit it. For an optimization, set [Tester].Optimization to one of:
1— slow complete algorithm2— fast genetic algorithm3— 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 bastard 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 optimization_cache. If you want the full mode-by-mode request/response examples, see docs/backtest-optimization.md.
POST /backtest/build-set
MT5 .set files are plain parameter files, typically UTF-16 text. A normal saved set looks like this:
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:
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:
TakeProfit=50||10||5||100||Y
StopLoss=30||10||5||80||Y
LotSize=0.1||0||0||0||N
TakeProfitandStopLossare optimized because theoptimizefield isY.LotSizestays fixed because theoptimizefield isN.
So a parameter you want optimized with a known range looks like this:
MyParam=50||10||5||200||Y
And a parameter you want fixed looks like this:
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:
{
"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: UTF-8 with LF line endings, each comments line prefixed with ; unless it already starts with ;. An entry without start, step, stop and optimize becomes a plain name=value line; an entry with only some of them is refused with 400.
Only one tester runs at a time per API process (serialized by an internal lock); additional submissions queue.
If the API restarts while a job is queued or running, startup recovery marks that orphaned job as failed with API restarted before completion. Recovery runs in a background thread so a large job history can never delay the API from serving, and only inspects jobs touched within the last BACKTEST_SWEEP_LOOKBACK (default 24h). On a mode: backtest terminal the same startup pass also kills that terminal’s leftover terminal64.exe and metatester64.exe processes. Pruning runs in that startup pass too, but only once the shared job directory holds 1000+ entries, and at most once a day per VM: finished jobs (completed, failed, cancelled) older than BACKTEST_JOB_RETENTION (default 30d) lose their state file and staging dir, so their status/report/log URLs return 404 from then on.
Asset sources
Send the expert and set file inline for one-off runs, or dump reusable ones in the 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 repository ships the warm-up EA source:
assets/experts/MT5SystemWarmup.mq5
Compile it inside Windows to produce the untracked assets/experts/MT5SystemWarmup.ex5 used by historical warm-up flows.
Regenerating MT5SystemWarmup.ex5
With the VM running and scripts synced, open noVNC, launch cmd.exe, and run:
C:\Users\Docker\Desktop\Shared\scripts\compile-warmup-ea.bat
What the script does:
- locates the first installed broker
baseterminal underC:\Users\Docker\Desktop\Shared\terminals\*\base - copies
C:\Users\Docker\Desktop\Assets\experts\MT5SystemWarmup.mq5into that terminal’sMQL5\Experts\Advisors\ - runs
MetaEditor64.exe /compile:... /log:... - copies the resulting
MT5SystemWarmup.ex5back intoC:\Users\Docker\Desktop\Assets\experts\
Compile log lands at:
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 (no path) |
from_date+to_date | one of | YYYY-MM-DD |
last_years | one of | integer; window ends today UTC |
last_days | one of | integer |
modelling | no | every-tick 1m-ohlc open-prices real-ticks (or MT5’s 0 1 2 4); default open-prices |
latency_ms | no | integer milliseconds, written as ExecutionMode; default 0 |
deposit | no | default 10000, must be > 0 |
currency | no | default USD, upper-cased |
leverage | no | default 100, must be > 0, written as 1:N |
expert_parameters | no | .set filename (no path) |
optimization | no | 0 off, 1 slow complete, 2 genetic, 3 Market Watch symbols |
optimization_criterion | no | 0..7; default 0 (max balance) |
forward_mode | no | 0..4; default 0 |
visual | no | truthy enables visual tester mode; default off |
report_name | no | default backtest-report.htm for backtests, optimization-report.xml for optimizations; .htm or .xml is appended when missing |
Returns text/plain with the generated INI. Both builders need Content-Type: application/json and answer 400 with an error string for a bad body.
Example JSON for an optimization INI:
{
"symbol": "GBPUSD",
"timeframe": "M15",
"expert": "MyEA.ex5",
"last_years": 3,
"modelling": "open-prices",
"expert_parameters": "myea-optimizer.set",
"optimization": 2,
"optimization_criterion": 5,
"report_name": "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/ |
top_passes | no | For optimization jobs, keep the top 1..500 parsed passes (cache or XML) in the status payload. Default 50. |
timeout | no | Duration string override ("30m", "6h", "3h30m"; a bare number means hours). Defaults to the BACKTEST_TIMEOUT env var, then backtest_timeout from config.yaml, then hardcoded 6h. Must be positive and at most BACKTEST_MAX_TIMEOUT (env, default 48h): out-of-range values are rejected with 400 rather than silently clamped, since the terminal’s run lock is held for the whole timeout. |
The INI upload must be UTF-8 text (a BOM is fine). An INI saved as UTF-16 is refused with 400 (the API itself converts it to UTF-16 for MT5). When both an upload and an asset name are sent, the upload wins. The whole multipart body is capped at 25 MiB (MAX_UPLOAD_BODY_BYTES).
Responds 202 Accepted with Retry-After header and the queued job payload:
{
"job_id": "b3f7…",
"status": "queued",
"broker": "darwinex",
"account": "tester",
"submitted_at": "2026-05-12T10:00:00Z",
"started_at": null,
"finished_at": null,
"duration_seconds": null,
"report_name": "backtest-report.htm",
"status_url": "/backtest/b3f7…",
"report_url": "/backtest/b3f7…/report",
"log_url": "/backtest/b3f7…/log",
"poll_after_seconds": 60,
"optimization_type": 0,
"optimization_results": null,
"optimization_cache": null,
"queue_position": 0
}
queue_position is 0-based and counts this process’s queued and running jobs in submission order, so 0 means the job runs next (or is running now). It is left out once the job is finished.
[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. The API also rewrites:
[Tester].ReporttoReports\<basename>. An empty one becomesbacktest-<hex>.htmoroptimization-<hex>.xml, and.htmor.xmlis appended when missing.ReplaceReport=1andShutdownTerminal=1are forced.[Tester].Symbol: the terminal’ssymbol_suffixis appended, unless the terminal’s cached broker symbol list has the bare name and lacks the suffixed one.
Errors from POST /backtest are JSON with an error string:
| Status | When |
|---|---|
400 | missing ini file; INI not UTF-8; INI without [Tester]; Optimization not 0..3; expert not .ex5 or set not .set; asset name not found or a path; bad timeout or top_passes; top_passes and topPasses with different values; broker/account missing from config.yaml |
413 | multipart body over 25 MiB |
500 | terminal64.exe not found for this terminal |
503 | code: WORKER_START_FAILED: the worker thread could not start; the job is stored as failed with Backtest worker could not be started |
When the broker’s server ships a new MT5 build, a launch of a terminal with the update pending starts an updater (<AppData>\MetaQuotes\Terminal\<hash>\liveupdate\terminal64.exe /update) and exits. If the next test relaunches the terminal before the updater has replaced terminal64.exe, the updater never finishes. So before every launch the API waits up to 120 s for a leftover updater of that terminal to exit, then stops it, and a timed-out run’s cleanup stops them too. Left alone they pile up, each holding about 48 GDI and 36 USER objects, until the Windows session runs out (65,536 GDI objects) and every terminal launch fails with exit 10053 or 10012 (“not enough handles to start the platform”).
GET /backtest/{job_id}
Status payload. status ∈ queued running completed failed cancelled. When completed, includes a summary object parsed from the HTML report (net_profit, profit_factor, recovery_factor, expected_payoff, sharpe_ratio, max_drawdown, total_trades, profit_trades, loss_trades, …).
For optimization jobs, the payload instead includes:
optimization_type: the submitted MT5 optimization mode (1,2, or3)optimization_results: a parsed top-N list sorted byResultdescendingoptimization_cache: cache artifact metadata when results came from an MT5.optcache file
Result source depends on the submitted mode:
- Every mode first looks for a matching
.optfile inTester/cache/and parses it when it can. - Modes
1and2fall back to the MT5 XML spreadsheet report when the cache gives no rows, which is the usual case for them. - Mode
3never parses the XML, because the.symbols.xmlreport does not contain the optimization rows.
For rows parsed from the XML report, 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 optimization_results row. Rows parsed from the cache have a fixed set of columns (see the mode-3 example below).
A job that ends failed or cancelled carries an error string:
error starts with | Meaning |
|---|---|
Backtest timed out after Ns | the run hit its timeout; leftover tester processes were killed (count appended) |
terminal64.exe exited with code N | non-zero exit, followed by the terminal log tail when there is one |
Report not generated | MT5 exited cleanly without a report, which is what a run on a terminal without mode: backtest ends in (the API does not check the mode) |
Backtest produced empty report (Bars=0, Ticks=0, Symbols=0) | MT5 had no market data; the payload keeps summary |
Backtest crashed: ... / Backtest worker crashed before the run finished: ... | an exception in the run or the worker |
Cannot prepare terminal directories: ... | Reports, MQL5\Experts\Uploaded or MQL5\Profiles\Tester could not be created |
API restarted before completion | startup recovery (see above) |
Cancelled by request | status cancelled |
Example optimization status payload:
{
"job_id": "8c2a…",
"status": "completed",
"broker": "darwinex",
"account": "tester",
"report_name": "gbpusd-m15-sharpe-search.xml",
"report_url": "/backtest/8c2a…/report",
"log_url": "/backtest/8c2a…/log",
"optimization_type": 2,
"optimization_cache": null,
"optimization_results": [
{
"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. Cache rows always have the columns Pass, Symbol, Result, Profit, Profit Factor, Expected Payoff, Recovery Factor, Sharpe Ratio, Equity DD %, Trades and Custom. Pass comes from the agent logs and falls back to the row’s index in the cache when no log line names that symbol. optimization_cache also carries path, size_bytes, modified_at, expert, from_date and to_date:
{
"job_id": "b05643…",
"status": "completed",
"broker": "darwinex",
"account": "live",
"report_name": "mode3-gbpcad-m15-last5y-rerun5.symbols.xml",
"report_url": "/backtest/b05643…/report",
"log_url": "/backtest/b05643…/log",
"optimization_type": 3,
"optimization_cache": {
"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",
"cache_hash": "788ECDD113BA3097A58EF888EBEFF9CA",
"row_count": 28,
"symbol_component": "all_symbols",
"period": "M15"
},
"optimization_results": [
{
"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/{job_id}/report & /log
/report streams the raw MT5 report: the HTML report for backtests, the XML spreadsheet export for optimizations (the .symbols.xml header export for mode 3). It answers 404 until MT5 has written the report.
/log streams run.log, the stdout/stderr of terminal64.exe (usually sparse). It is created when the terminal launches, so it is readable while the job runs and answers 404 before that (and for a job that failed or was cancelled before launch). The MT5 terminal and tester journals are in /tail.
GET /backtest/{job_id}/tail
Return the live diagnostic guts for queued, running, or finished jobs as JSON. ?lines=N controls the terminal/tester journal depth and is clamped to 10..1000 (default 200). The response includes job_id, status, started_at, finished_at, error, terminal_log, tester_log, the selected journal filenames (terminal_log_file, tester_log_file), and run_log, which is always the last 50 lines of run.log whatever lines says.
POST /backtest/{job_id}/cancel
Stop a job and mark it cancelled. Safe to repeat.
| Job | What happens | Answer |
|---|---|---|
| unknown | nothing | 404, code: JOB_NOT_FOUND |
| another terminal’s (asked on the wrong instance) | nothing | 409, code: JOB_NOT_OWNED |
completed, failed or cancelled | nothing | 200, the job as it is |
queued | dropped before it launches; a run already on this terminal is not touched | 200 |
running | this terminal’s terminal64.exe, its metatester64.exe agents and any stuck LiveUpdate updater are killed; the next job starts only once they are gone | 200, or 202 if the worker is still stopping after 40 s |
The answer is the job’s status payload. A job this call cancelled carries error: "Cancelled by request" and also gets killed (processes signalled) and stopped (the worker has let go of the terminal); a job that was already finished comes back without them. cancelled is final: a write from the worker that is still winding down cannot turn it back into failed or completed. A cancelled job usually has no report: /report serves one only if MT5 had already written it when the cancel landed.
A job’s worker can no longer die and leave the job running: any exception outside the run’s own error handling fails the job with Backtest worker crashed before the run finished: ..., and a state file that cannot be written on the VM share (WinError 5) is retried and then kept in memory instead of ending the run.
Scheduled reboots
GET /busy lists every queued or running backtest of this terminal, and a cancelled one whose worker is still stopping the tester processes, so a scheduled VM reboot waits for them (up to reboot_max_postpone, default 360 minutes, see scripts/reboot_guard.py). While the reboot is about to happen, every POST here (/backtest, build-ini, build-set, cancel) answers 503 with code: REBOOT_PENDING and Retry-After: 120.
Worked example
export URL=http://127.0.0.1:8888/darwinex/tester
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","last_years":5,"modelling":"open-prices","latency_ms":5,"expert_parameters":"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 .job_id)
# 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 || "$STATUS" == cancelled ]] && 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.
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","last_years":1,"modelling":"open-prices","expert_parameters":"ea studio gbpcad m15 1615044595.take-profit-opt-80-92-step4.set","optimization":1,"optimization_criterion":0,"report_name":"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 "top_passes=20" \
> "$job_json" && \
JOB=$(jq -r '.job_id' "$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 || "$STATUS" == cancelled ]] && break; \
sleep 10; \
done && \
echo && echo "Final API summary:" && \
printf '%s\n' "$STATUS_JSON" | jq '{job_id,status,exit_code,duration_seconds,optimization_results}' && \
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
.setfile. If no ranges are enabled in MT5, optimization is not meaningful. optimization_resultsis a convenience summary. For modes1and2, the raw XML at/reportremains the full source of truth. For mode3, the parsed.optcache plusoptimization_cachemetadata are the best debugging source because/reportis the MT5.symbols.xmlheader export.- If a metric you expect is missing from
optimization_resultsin mode1or2, first check the raw XML report. For XML rows the API preserves MT5’s exported columns rather than remapping them to a fixed schema; mode3cache rows have the fixed column set listed above.