Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ CODEX.local.md
.research_files
data/*
!data/.gitkeep
# Terminal data tree scaffolded at repo root when mt5api runs outside the VM
# (config.py resolves BROKERS_DIR relative to the repo). Never commit it —
# a real terminal dir contains account artifacts.
/terminals/
mt5installers/*
!mt5installers/.gitkeep
# Host-managed backtest assets — referenced by /backtest via expert_name/set_name.
Expand All @@ -24,6 +28,7 @@ config/reboot_interval.txt
config/requirements.txt
.env
docker-compose.yml
vms.yaml
run.log
logs/
__pycache__
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ The project follows [Semantic Versioning](https://semver.org/): patch = bug fixe

- Fresh terminal API and MCP-unifier installs now pin MCP SDK 1.28.0. MCP 2.0 removed `mcp.server.fastmcp`, causing both processes to fail during import before binding their HTTP ports.

## [v4.10.0] — 2026-07-28

### Added

- **N-VM topology** — `vms.yaml` declares VM resource allocation (cpuset, RAM, CPU cores, disk, hot-tier storage) in one place. `config_helper.py` reads the `vm` field on each terminal entry to generate nginx routes to the correct container (e.g. `mt5`, `mt5-b`), and `run.sh` iterates over all VMs for DNAT/iptables setup. Existing single-VM users are unaffected: no `vms.yaml` → all terminals route to the default `mt5` container.
- **`terminals[].vm`** — optional field in `config/config.yaml` that assigns a terminal to a specific VM. Absent → `default` (routes to `mt5`). See `vms.yaml` for VM names.
- **`config_helper.py`** new commands: `vms` (list VM names), `vm_group <name>` (dump terminals for a VM), `vm_info <name> [field]`, `port_list --vm <name>`, `generate_compose` (render `docker-compose.yml` from `docker-compose.yml.j2` + `vms.yaml`).
- **`docker-compose.yml.j2`** — Jinja2 template that renders the compose file from the VM definitions in `vms.yaml`. Used by `run.sh` on first boot when `vms.yaml` exists.
- **`docs/multi-vm-setup.md`** — guide for setting up a multi-VM deployment.
- **Per-VM concurrency caps** — set `MT5_HTTPAPI_MAX_IN_FLIGHT_<NAME>` environment variable to cap in-flight backtests per VM independently (in addition to the global `MT5_HTTPAPI_MAX_IN_FLIGHT`).

### Changed

- `run.sh` generates per-VM group files (`vm-group-<name>.txt`) from `config.yaml` instead of requiring manually maintained `vm-group-*.txt` files.
- `config_helper.py` nginx_conf now routes each terminal to its owning VM's container name, derived from `vms.yaml`.

## [v4.9.3] — 2026-07-28

### Fixed
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,53 @@ Per-field notes:
- **`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.
- **`terminals[].vm`** — optional VM name that this terminal runs on. Maps to a VM defined in `vms.yaml`. Absent → `default` (routes to the `mt5` container). See [Multi-VM Setup](#multi-vm-setup) below.

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.

### `vms.yaml` (optional — multi-VM deployments)

When you need more than one Windows VM (e.g. spreading terminals across NUMA nodes, or isolating a hot-SSD tier from bulk-HDD terminals), define the VM topology in `vms.yaml`. The file is **optional** — absent = single-VM mode (backward compatible, everything routes to the `mt5` container).

```yaml
vms:
- name: fast
service: mt5
container_name: mt5
cpuset: "0-19,40-59"
ram: "112G"
cpu_cores: 40
disk_size: "300G"
storage: /data/mt5-vm-a/storage
novnc_port: 8006
wickworks_service: wickworks
extra_binds:
- /mnt/ssd/terminals/darwinex/live/a:/shared/terminals/darwinex/live/a

- name: bulk
service: mt5-b
container_name: mt5-b
cpuset: "20-39,60-79"
ram: "112G"
cpu_cores: 40
disk_size: "150G"
storage: /data/mt5-vm-b/storage
novnc_port: 8007
wickworks_service: wickworks-b
extra_binds:
- /mnt/hdd/terminals/blackbull/live-prime:/shared/terminals/blackbull/live-prime
```

Each terminal in `config.yaml` references its VM via `vm: <name>`. Terminals route through nginx to the correct container:

| Terminal | Routes to | Container |
|---|---|---|
| `vm: fast` | `proxy_pass http://mt5:<port>` | mt5 |
| `vm: bulk` | `proxy_pass http://mt5-b:<port>` | mt5-b |
| no `vm` | `proxy_pass http://mt5:<port>` | mt5 (default) |

See [`docs/multi-vm-setup.md`](docs/multi-vm-setup.md) for the full walkthrough — NUMA pinning, hot-tier bind mounts, per-VM concurrency caps, and the Jinja2 compose template.

### `config/setup.bat`

Custom commands that run on every VM boot before MT5 starts. Shove whatever Windows setup shit you need in here.
Expand Down Expand Up @@ -1679,6 +1723,9 @@ config/ Your config shit
setup.bat Custom boot commands (optional)
hosts Extra entries for the VM's hosts file (optional)

vms.yaml VM topology definition (optional — absent = single VM)
docker-compose.yml.j2 Jinja2 template for N-VM compose generation

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
Expand Down
4 changes: 4 additions & 0 deletions config/config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ accounts:
# freeing the data dir so the tester can spawn it via /portable /config:.
# (MT5 is single-instance per portable data dir — a live terminal locks
# the dir and any second terminal64.exe exits silently with code 0.)
# vm: optional VM name from vms.yaml. Routes this terminal's API to the
# specified VM container. Absent = default (routes to "mt5").
# See docs/multi-vm-setup.md for multi-VM setup.
# symbol_suffix: optional broker-specific suffix appended to [Tester].Symbol
# when missing. Examples: "p", ".p", "-mini". Use "" for no suffix.
terminals:
Expand All @@ -70,6 +73,7 @@ terminals:
utc_offset: "0"
mode: live
symbol_suffix: ""
# vm: my-vm-name # uncomment to pin this terminal to a specific VM

# Same login cloned multiple times: every clone gets its own instance name
# and its own port.
Expand Down
205 changes: 205 additions & 0 deletions docker-compose.yml.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
services:
{% for vm in vms %}
# ── VM "{{ vm.name }}" — {{ vm.cpuset }} ──
{{ vm.service }}:
image: dockurr/windows:5.14
environment:
RAM_SIZE: "{{ vm.ram }}"
RAM_CHECK: "N"
CPU_CORES: "{{ vm.cpu_cores }}"
DISK_SIZE: "{{ vm.disk_size }}"
devices:
- /dev/kvm
cap_add:
- NET_ADMIN
{% if vm.cpuset %}
cpuset: "{{ vm.cpuset }}"
{% endif %}
ports:
{% if vm.novnc_port %}
- "{{ vm.novnc_port }}:8006"
{% else %}
- "${NOVNC_PORT:-8006}:8006"
{% endif %}
volumes:
{% if vm.storage %}
- {{ vm.storage }}:/storage
{% endif %}
- ./data/oem:/oem
{% if vm.log_dir %}
- {{ vm.log_dir }}:/shared/logs
{% endif %}
- /data/mt5-shared:/shared
- ./data/win.iso:/boot.iso
- ./assets:/shared/assets:ro
{% if vm.extra_binds %}
{% for bind in vm.extra_binds %}
- {{ bind }}
{% endfor %}
{% endif %}
- ./data/vm-group-{{ vm.name }}.txt:/shared/config/vm-group.txt:ro
{% if vm.mem_limit %}
deploy:
resources:
limits:
memory: {{ vm.mem_limit }}
{% if vm.memswap_limit %}
memswap_limit: {{ vm.memswap_limit }}
{% endif %}
{% else %}
deploy:
resources:
limits:
memory: 116G
memswap_limit: 120G
{% endif %}
healthcheck:
test: ["CMD", "sh", "/shared/scripts/healthcheck.sh"]
interval: 30s
timeout: 30s
retries: 10
start_period: 120s
restart: unless-stopped
stop_grace_period: 2m

{% if vm.wickworks_service %}
# Wickworks sidecar for VM "{{ vm.name }}"
{{ vm.wickworks_service }}:
image: psyb0t/wickworks:v0.3.1
restart: unless-stopped
network_mode: "service:{{ vm.service }}"
environment:
LOG_LEVEL: INFO
MAX_BARS: "5000"
MIN_BARS: "50"
depends_on:
- {{ vm.service }}

{% endif %}
{% endfor %}
# Daily log rotator. Rotates data/shared/logs/*.log to *.log.YYYYMMDD
# at the day boundary and prunes archives older than RETAIN_DAYS.
# Truncate-in-place so the Python API's open log handles keep working
# without reopening. Hourly check, idempotent (keyed on yesterday's
# archive existing).
log-rotator:
image: alpine:3.20
restart: unless-stopped
environment:
LOG_DIR: /logs
RETAIN_DAYS: "7"
INTERVAL: "3600"
volumes:
- /data/mt5-shared/logs:/logs
- ./scripts/rotate-logs.sh:/rotate.sh:ro
command: ["sh", "/rotate.sh"]

{% if enable_mcpunifier|default(true) %}
# Unified MCP endpoint. One MCP session that reaches every terminal, with
# broker/account as tool parameters, instead of one endpoint per terminal.
# The per-terminal /<broker>/<account>/mcp endpoints keep working unchanged;
# nginx routes /mcp/ here.
mcpunifier:
build:
context: .
dockerfile: Dockerfile.mcpunifier
restart: unless-stopped
environment:
MT5_HOST: {{ vms[0].service if vms else 'mt5' }}
LOG_LEVEL: ${MCP_LOG_LEVEL:-info}
volumes:
- ./config/config.yaml:/app/config/config.yaml:ro
security_opt:
- "no-new-privileges:true"
cap_drop:
- ALL
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=16m
- /var/log/mcpunifier:rw,noexec,nosuid,size=64m
init: true
deploy:
resources:
limits:
memory: 256M
cpus: "0.5"
pids: 128
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:6600/health', timeout=3).status == 200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
depends_on:
{%- for vm in vms %}
- {{ vm.service }}
{%- endfor %}
{%- endif %}
# nginx is the single entry point for all terminal APIs. Routes
# /<broker>/<account>/... to <vm_service>:<terminal_port> (per-terminal Python
# API process inside each Windows VM, reachable via the VM container's
# iptables DNAT). Auto-generated from config/config.yaml by run.sh.
# Bound to 127.0.0.1:8888 so it's loopback-only by default — LAN
# exposure is opt-in (change the host bind), tailnet exposure is via
# the optional tailscale sidecar below.
nginx:
image: nginx:1.30.0-alpine3.23
restart: unless-stopped
ports:
- "127.0.0.1:${API_HOST_PORT:-8888}:80"
volumes:
- ./.data/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
{%- for vm in vms %}
- {{ vm.service }}
{%- endfor %}
{%- if enable_mcpunifier|default(true) %}
- mcpunifier
{%- endif %}

# ── Cloudflare Tunnel (uncomment to expose API publicly) ──────────
# Point your cloudflared ingress to http://nginx:80 (single backend,
# nginx routes per-terminal paths). Drop creds + config in
# ./.data/cloudflared/.
# cloudflared:
# image: cloudflare/cloudflared:2026.3.0
# restart: unless-stopped
# command: tunnel --config /etc/cloudflared/config.yml run
# volumes:
# - ./.data/cloudflared/config.yml:/etc/cloudflared/config.yml:ro
# - ./.data/cloudflared/creds.json:/etc/cloudflared/creds.json:ro
# depends_on:
# - nginx

# ── Tailscale (uncomment to expose API over tailnet HTTP) ─────────
# Set tailscale.auth_key in config/config.yaml; for Headscale, also set
# tailscale.login_server. run.sh reads both and writes .env + wires
# tailscale serve via the CLI inside the sidecar. URL scheme:
# http://mt5-httpapi/<broker>/<account>/...
# Plain HTTP — bare MagicDNS hostnames don't have matching certs, and
# the wireguard layer already encrypts everything inside the tailnet.
# The sidecar runs in its OWN netns (bridge mode, not host) so it gets
# its own tailnet identity — host's tailscale (if any) stays clean and
# ACLs scope to the container's node only.
# tailscale:
# image: tailscale/tailscale:v1.96.5
# restart: unless-stopped
# environment:
# - TS_AUTHKEY=${TS_AUTHKEY:-}
# - TS_HOSTNAME=${TS_HOSTNAME:-mt5-httpapi}
# - TS_STATE_DIR=/var/lib/tailscale
# - TS_USERSPACE=false
# - TS_EXTRA_ARGS=${TS_EXTRA_ARGS:---accept-dns=false}
# volumes:
# - ./.data/tailscale/state:/var/lib/tailscale
# - /dev/net/tun:/dev/net/tun
# cap_add:
# - NET_ADMIN
# - NET_RAW
# depends_on:
# - nginx
Loading