initial import

This commit is contained in:
bast 2026-11-06 14:22:28 +01:00
commit 2fcbbfc3c9
32 changed files with 4998 additions and 0 deletions

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
.devcontainer
.git
docker-compose.yaml
README.md
LICENSE.md
.gitignore
.env.example
.env
config

4
.env.example Normal file
View file

@ -0,0 +1,4 @@
UID=1000
GID=1000
CUSTOM_USER=username
PASSWORD=securepassword

112
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,112 @@
name: MT5 Helm GitOps Deploy
on:
push:
branches: [ "main" ]
tags: [ "*" ]
workflow_dispatch:
env:
# Gleiche Registry wie im boday_tf-Projekt, zwei Namen:
# PUSH_REGISTRY: wie der Runner sie erreicht
# PULL_REGISTRY: wie k3s/containerd sie erreicht (registries.yaml, CA unter certs.d)
PUSH_REGISTRY: localhost:3005
PULL_REGISTRY: registry.local
# Host-Header für Traefik-Ingress des Web-UI (leer = kein Ingress, kein Health-Check per HTTP)
INGRESS_HOST: mt5.local
jobs:
build-and-deploy:
if: false
runs-on: ubuntu-latest
container: catthehacker/ubuntu:act-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
# HINWEIS: Terraform-Binary kommt wie bei boday_tf über das globale Runner-Volume (:ro)
- name: Trust Local Nginx Certificate Inside Container
run: |
mkdir -p /usr/local/share/ca-certificates/
cp nginx_ssl/nginx.crt /usr/local/share/ca-certificates/nginx-sandbox.crt 2>/dev/null || echo "Nutze Workspace-Zertifikat"
update-ca-certificates
# Docker verlangt Image-Namen in Kleinbuchstaben – "MetaTrader5-Docker-Image" würde sonst scheitern
- name: Compute Image Name
run: |
IMAGE_PATH="$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')"
echo "IMAGE=${PUSH_REGISTRY}/${IMAGE_PATH}" >> "$GITHUB_ENV"
echo "PULL_IMAGE=${PULL_REGISTRY}/${IMAGE_PATH}" >> "$GITHUB_ENV"
echo "Push: ${PUSH_REGISTRY}/${IMAGE_PATH}"
echo "Pull: ${PULL_REGISTRY}/${IMAGE_PATH}"
- name: Log in to Local Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${PUSH_REGISTRY} -u ${{ github.actor }} --password-stdin
# Ein Build, zwei Tags (das Image ist ~4 GB – doppelt bauen kostet unnötig Zeit)
- name: Build MT5 Image
run: |
docker build --platform linux/amd64 \
-t ${IMAGE}:${{ github.sha }} \
-t ${IMAGE}:latest \
.
- name: Push MT5 Image
run: |
docker push ${IMAGE}:${{ github.sha }}
docker push ${IMAGE}:latest
- name: Prepare Kubernetes Access
run: |
echo "${{ secrets.KUBECONFIG_DATA }}" > ./kind_kubeconfig
chmod 600 ./kind_kubeconfig
# Erster Rollout kann bis 15 Min dauern (Wine + MT5 Installation nach /config)
# init mit Retry, weil der DNS-Resolver sporadisch "server misbehaving" liefert
- name: Terraform Deploy
env:
# Web-UI-Login (KasmVNC) – wie bisher
TF_VAR_vnc_user: ${{ secrets.MT5_VNC_USER || 'trader' }}
TF_VAR_vnc_password: ${{ secrets.MT5_VNC_PASSWORD }}
# Broker-Login für MT5 – landet als login.ini im Pod und wird beim Start geladen
TF_VAR_mt5_login: ${{ secrets.MT5USER }}
TF_VAR_mt5_password: ${{ secrets.MT5PASSWORD }}
TF_VAR_mt5_server: ${{ secrets.MT5_SERVER }}
run: |
terraform --version
for i in 1 2 3; do
terraform init && break
if [ "$i" = "3" ]; then echo "terraform init nach 3 Versuchen fehlgeschlagen"; exit 1; fi
echo "init fehlgeschlagen, Versuch $i – neuer Anlauf in 10s"
sleep 10
done
terraform apply -auto-approve \
-var="image_repository=${PULL_IMAGE}" \
-var="app_version=${{ github.sha }}" \
-var="ingress_host=${INGRESS_HOST}"
# KasmVNC antwortet ohne Login mit 401 – daher mit Basic Auth prüfen und auf den Erststart warten
- name: Verify App Health
if: env.INGRESS_HOST != ''
env:
VNC_USER: ${{ secrets.MT5_VNC_USER || 'trader' }}
VNC_PASS: ${{ secrets.MT5_VNC_PASSWORD }}
run: |
for i in $(seq 1 40); do
CODE=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout 5 \
-u "${VNC_USER}:${VNC_PASS}" -H "Host: ${INGRESS_HOST}" \
http://local-k3s-platform:80/ || true)
echo "Versuch ${i}: HTTP ${CODE}"
if [ "${CODE}" = "200" ]; then
echo "Erfolg: MetaTrader 5 Web-UI läuft via Helm im GitOps-Standard!"
exit 0
fi
sleep 15
done
echo "MT5 Web-UI nach 10 Minuten nicht erreichbar"
exit 1
- name: Cleanup Kubeconfig
if: always()
run: rm -f ./kind_kubeconfig

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
trader-data
config
.env

39
Dockerfile Normal file
View file

@ -0,0 +1,39 @@
FROM ghcr.io/linuxserver/baseimage-kasmvnc:debianbookworm
# set version label
ARG BUILD_DATE
ARG VERSION
LABEL build_version="Metatrader Docker:- ${VERSION} Build-date:- ${BUILD_DATE}"
LABEL maintainer="gmartin"
ENV TITLE=Metatrader5
ENV WINEPREFIX="/config/.wine"
ENV WINEDEBUG=-all
# Install all packages in a single layer to reduce image size
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3 \
python3-pip \
python3-venv \
wget \
curl \
gnupg2 \
software-properties-common \
ca-certificates \
&& mkdir -pm755 /etc/apt/keyrings \
&& wget -O /etc/apt/keyrings/winehq-archive.key https://dl.winehq.org/wine-builds/winehq.key \
&& wget -NP /etc/apt/sources.list.d/ https://dl.winehq.org/wine-builds/debian/dists/bookworm/winehq-bookworm.sources \
&& dpkg --add-architecture i386 \
&& apt-get update \
&& apt-get install --install-recommends -y winehq-stable \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /etc/apt/keyrings/winehq-archive.key
COPY /Metatrader /Metatrader
RUN chmod +x /Metatrader/start.sh
COPY /root /
EXPOSE 3000 8001
VOLUME /config

22
LICENSE.md Normal file
View file

@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2023 Germán Martín
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

175
Metatrader/start.sh Executable file
View file

@ -0,0 +1,175 @@
#!/bin/bash
#
# MT5 im Container – Variante mit Broker-Terminal (PU Prime) und 64-Bit-Python.
# Gründe:
# - Das MetaTrader5-Python-Paket gibt es für 32-Bit-Python nur bis 5.0.37 (2022).
# Das hängt bei aktuellen Terminal-Builds in initialize(). 64-Bit-Python bekommt
# das aktuelle Paket.
# - Das Broker-Terminal bringt die PU-Prime-Server vorkonfiguriert mit.
# Configuration variables
WINEPREFIX='/config/.wine'
WINEDEBUG='-all'
wine_executable="wine"
mt5server_port="8001"
MT5_CMD_OPTIONS="${MT5_CMD_OPTIONS:-}"
mono_url="https://dl.winehq.org/wine/wine-mono/10.3.0/wine-mono-10.3.0-x86.msi"
python_url="https://www.python.org/ftp/python/3.11.9/python-3.11.9-amd64.exe"
# Broker-Installer wird mit ins Image kopiert (Metatrader/vantageinternational5setup.exe).
# Fehlt er, wird das generische MetaQuotes-Terminal installiert.
broker_setup="/Metatrader/vantageinternational5setup.exe"
mt5setup_url="https://download.mql5.com/cdn/web/metaquotes.software.corp/mt5/mt5setup.exe"
program_files="/config/.wine/drive_c/Program Files"
# Function to display a graphical message
show_message() {
echo "$1"
}
# Function to check if a dependency is installed
check_dependency() {
if ! command -v $1 &> /dev/null; then
echo "$1 is not installed. Please install it to continue."
exit 1
fi
}
# pip show statt pkg_resources (in neueren Pythons nicht mehr zuverlässig vorhanden)
is_python_package_installed() {
python3 -m pip show "$1" >/dev/null 2>&1
}
is_wine_python_package_installed() {
$wine_executable python -m pip show "$1" >/dev/null 2>&1
}
# Terminal suchen – Broker-Installer legen es unter eigenem Ordnernamen ab
find_terminal() {
find "$program_files" -maxdepth 2 -iname terminal64.exe 2>/dev/null | head -1
}
# Ist das Python in Wine ein 64-Bit-Python?
wine_python_is_64bit() {
$wine_executable python -c "import struct,sys; sys.exit(struct.calcsize('P') != 8)" 2>/dev/null
}
# Check for necessary dependencies
check_dependency "curl"
check_dependency "$wine_executable"
# Install Mono if not present
if [ ! -e "/config/.wine/drive_c/windows/mono" ]; then
show_message "[1/7] Downloading and installing Mono..."
curl -o /config/.wine/drive_c/mono.msi $mono_url
WINEDLLOVERRIDES=mscoree=d $wine_executable msiexec /i /config/.wine/drive_c/mono.msi /qn
rm /config/.wine/drive_c/mono.msi
show_message "[1/7] Mono installed."
else
show_message "[1/7] Mono is already installed."
fi
# Check if MetaTrader 5 is already installed
mt5file="$(find_terminal)"
if [ -n "$mt5file" ]; then
show_message "[2/7] Terminal found: $mt5file"
else
show_message "[2/7] No MT5 terminal installed. Installing..."
# Set Windows 10 mode in Wine
$wine_executable reg add "HKEY_CURRENT_USER\\Software\\Wine" /v Version /t REG_SZ /d "win10" /f
if [ -f "$broker_setup" ]; then
show_message "[3/7] Using broker installer $broker_setup"
cp "$broker_setup" /config/.wine/drive_c/mt5setup.exe
else
show_message "[3/7] Downloading generic MT5 installer..."
curl -o /config/.wine/drive_c/mt5setup.exe $mt5setup_url
fi
show_message "[3/7] Installing MetaTrader 5..."
$wine_executable "/config/.wine/drive_c/mt5setup.exe" "/auto" &
wait
rm -f /config/.wine/drive_c/mt5setup.exe
# Manche Installer beenden sich, bevor der Download fertig ist – kurz warten
for i in $(seq 1 60); do
mt5file="$(find_terminal)"
[ -n "$mt5file" ] && break
sleep 5
done
fi
# Recheck if MetaTrader 5 is installed
if [ -n "$mt5file" ] && [ -e "$mt5file" ]; then
show_message "[4/7] Terminal installed: $mt5file. Running MT5..."
$wine_executable "$mt5file" $MT5_CMD_OPTIONS &
else
show_message "[4/7] No terminal64.exe found under $program_files. MT5 cannot be run."
fi
# Install 64-bit Python in Wine if not present
if wine_python_is_64bit; then
show_message "[5/7] 64-bit Python is already installed in Wine: $($wine_executable python --version 2>&1)"
else
if $wine_executable python --version >/dev/null 2>&1; then
show_message "[5/7] WARNING: Wine Python is 32-bit – installing 64-bit Python in addition."
show_message "[5/7] For a clean setup, delete ./config and start again."
fi
show_message "[5/7] Installing 64-bit Python in Wine..."
curl -L $python_url -o /tmp/python-installer.exe
$wine_executable /tmp/python-installer.exe /quiet InstallAllUsers=1 PrependPath=1 Include_test=0
rm /tmp/python-installer.exe
show_message "[5/7] Python installed in Wine: $($wine_executable python --version 2>&1)"
fi
# Upgrade pip and install required packages
show_message "[6/7] Installing Python libraries"
$wine_executable python -m pip install --upgrade --no-cache-dir pip
# MetaTrader5: current release (supports current terminal builds and numpy 2)
show_message "[6/7] Installing MetaTrader5 library in Windows"
if ! is_wine_python_package_installed "MetaTrader5"; then
$wine_executable python -m pip install --no-cache-dir MetaTrader5
fi
# RPyC server in Wine: mt5linux 0.1.9 (old CLI with -w), its own dependencies
# are ancient pins -> --no-deps, rpyc/plumbum installed explicitly
show_message "[6/7] Checking and installing mt5linux library in Windows if necessary"
if ! is_wine_python_package_installed "mt5linux"; then
$wine_executable python -m pip install --no-cache-dir --no-deps "mt5linux==0.1.9"
fi
if ! is_wine_python_package_installed "rpyc"; then
$wine_executable python -m pip install --no-cache-dir "rpyc>=6,<7" plumbum
fi
# Install python-dateutil if needed (datetime is built-in, but dateutil adds features)
if ! is_wine_python_package_installed "python-dateutil"; then
show_message "[6/7] Installing python-dateutil library in Windows"
$wine_executable python -m pip install --no-cache-dir python-dateutil
fi
# Install mt5linux library in Linux if not installed
show_message "[6/7] Checking and installing mt5linux library in Linux if necessary"
if ! is_python_package_installed "mt5linux"; then
pip install --break-system-packages --no-cache-dir --no-deps mt5linux==0.1.9 && \
pip install --break-system-packages --no-cache-dir "rpyc>=6,<7" plumbum numpy
fi
# Install pyxdg library in Linux if not installed
show_message "[6/7] Checking and installing pyxdg library in Linux if necessary"
if ! is_python_package_installed "pyxdg"; then
pip install --break-system-packages --no-cache-dir pyxdg
fi
# Start the MT5 server on Linux (runs the RPyC classic server inside Wine Python)
show_message "[7/7] Starting the mt5linux server..."
python3 -m mt5linux --host 0.0.0.0 -p $mt5server_port -w $wine_executable python.exe &
# Give the server some time to start
sleep 5
# Check if the server is running
if ss -tuln | grep ":$mt5server_port" > /dev/null; then
show_message "[7/7] The mt5linux server is running on port $mt5server_port."
else
show_message "[7/7] Failed to start the mt5linux server on port $mt5server_port."
fi

Binary file not shown.

244
README.md Normal file
View file

@ -0,0 +1,244 @@
# MetaTrader5 Docker Image
This project provides a Docker image for running MetaTrader5 with remote access via VNC, based on the [KasmVNC](https://github.com/kasmtech/KasmVNC) project and [KasmVNC Base Image from LinuxServer](https://github.com/linuxserver/docker-baseimage-kasmvnc).
## Features
- Run MetaTrader5 in an isolated environment.
- Remote access to MetaTrader5 interface via an integrated VNC client accessible through a web browser.
- Built on the reliable and secure [KasmVNC](https://github.com/kasmtech/KasmVNC) project.
- RPyC server for remote access to Python MetaTrader Library from Windows or Linux using <https://github.com/lucas-campagna/mt5linux>
![MetaTrader5 running inside container and controlled through web browser](https://imgur.com/v6Hm9pa.png)
----------
**NOTICE:**
Due to some compatibility issued, version 2 has switched its base from Alpine to Debian Linux. This and adding Python environment makes that container size is considerably bigger from about 600 MB to 4 GB.
If you just need to run Metatrader for running your MQL5 programs without any Python programming I recommend to go on using version 1.0. MetaTrader program is updated independently from image so you will always have latest MT5 version.
----------
## Requirements
- Docker installed on your machine.
- Only intelx86/amd64 host is supported
## Usage from repository
1. Clone this repository:
```bash
git clone https://github.com/gmag11/MetaTrader5-Docker-Image
cd MetaTrader5-Docker-Image
```
2. Build the Docker image:
```bash
docker build -t mt5 .
```
3. Run the Docker image:
```bash
docker run -d -p 3000:3000 -p 8001:8001 -v config:/config mt5
```
Now you can access MetaTrader5 via a web browser at localhost:3000.
On first run it may take a few minutes to get everything installed and running. Normally it takes less than 5 minutes. You don't need to do anything. All installation process is automatic and you should end up with MetaTrader5 running in your web session.
## Usage with docker compose with image form Docker Registry (preferred way)
1. Create a folder in a path where you have permission. For instance in your home.
```bash
mkdir MT5
cd MT5
```
2. Create `docker-compose.yaml` file.
```bash
nano docker-compose.yaml
```
Use this content filling user and password with your own data.
```yaml
version: '3'
services:
mt5:
image: gmag11/metatrader5_vnc
container_name: mt5
volumes:
- ./config:/config
ports:
- 3000:3000
- 8001:8001
environment:
- CUSTOM_USER=<Choose a user>
- PASSWORD=<Choose a secure password>
```
**Notice**: If you do not need to do remote python programming you can get a much smaller installation changing this line:
```yaml
image: gmag11/metatrader5_vnc
```
by this one
```yaml
image: gmag11/metatrader5_vnc:1.1
```
----------
**Notice**: Due to Windows permission management, if you are using windows use Docker managed volume instead of bind mount. Use this compose file instead:
```yaml
version: '3'
services:
mt5:
image: gmag11/metatrader5_vnc
container_name: mt5
volumes:
- mt5_config:/config
ports:
- 3000:3000
- 8001:8001
environment:
- CUSTOM_USER=<Choose a user>
- PASSWORD=<Choose a secure password>
volumes:
mt5_config:
```
----------
1. Start the container
```bash
docker compose up -d
```
In some systems `docker compose` command does not exists. Try to use `docker-compose up -d` instead.
4. Connect to web interface
Start your browser pointing `http://<your ip address>:3000`
On first run it may take a few minutes to get everything installed and running. Normally it takes less than 5 minutes. You don't need to do anything. All installation process is automatic and you should end up with MetaTrader5 running in your web session.
## Where to place MQ5 and EX5 files
In the case you want to run your own MQL5 bots inside the container you can find MQL5 folder structure in
```bash
config/.wine/drive_c/Program Files/MetaTrader 5/MQL5
```
All files that you place there can be accessed from your MetaTrader container without the need to restart anything.
You can access MetaEditor program clicking in `IDE` button in MetaTrader5 interface.
**Notice**: If you will run MQL5 only bots (without Python) you can run perfectly with gmag11/metatrader5_vnc:1.0 image as pointed before. Remember that **image version is not stuck to a specific MetaTrader 5 version**.
**Metatrader will always be updated automatically to latest version as it does when it is nativelly installed in Windows.**
## Python programming
You need to install [mt5linux library](https://github.com/lucas-campagna/mt5linux) in your Python host. It may be in any OS, not only Linux.
This is a simple snippet to run your Python script fron any host
```python
from mt5linux import MetaTrader5
mt5 = MetaTrader5(host='host running docker container',port=8001)
mt5.initialize()
print(mt5.version())
```
Output should be something like this:
```python
(mt5linux) linux:~/$ python3
Python 3.10.13 (main, Dec 26 2023, 20:21:41) [GCC 13.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from mt5linux import MetaTrader5
>>> mt5 = MetaTrader5(host='192.168.1.10',port=8001)
>>> mt5.initialize()
True
>>> print(mt5.version())
(500, 4120, '22 Dec 2023')
>>>
```
## Configuration
The port configuration can be adjusted as per the instructions in the KasmVNC repository.
### MetaTrader 5 Command Line Options
You can pass command line options to MetaTrader 5 using the `MT5_CMD_OPTIONS` environment variable. This is useful for custom configurations, tester modes, or other MetaTrader command line parameters.
Example using docker-compose:
```yaml
version: '3'
services:
mt5:
image: gmag11/metatrader5_vnc
container_name: mt5
volumes:
- mt5_config:/config
ports:
- 3000:3000
- 8001:8001
environment:
- CUSTOM_USER=<Choose a user>
- PASSWORD=<Choose a secure password>
- MT5_CMD_OPTIONS=/config:C:\\customized_for_tester_etc.ini
volumes:
mt5_config:
```
Example using docker run:
```bash
docker run -d -p 3000:3000 -p 8001:8001 \
-e MT5_CMD_OPTIONS="/config:C:\\customized_for_tester_etc.ini" \
-v mt5_config:/config \
gmag11/metatrader5_vnc
```
Common MetaTrader 5 command line options:
- `/config:<path>` - Use a specific configuration file
- `/login:<account>` - Automatically login to specified account
For a complete list of available options, refer to the [MetaTrader 5 documentation](https://www.metatrader5.com/en/terminal/help/start_advanced/start).
## Contributions
Feel free to contribute to this project. All contributions are welcome. Open an issue or create a pull request.
## License
This project is licensed under the terms of the [MIT license](https://opensource.org/license/mit/).
The [**KasmVNC**](https://github.com/kasmtech/KasmVNC) project is licensed under the [GNU General Public License v2.0 (GPLv2)](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html). You can check the license details of KasmVNC [here](https://github.com/kasmtech/KasmVNC/blob/master/LICENSE.TXT).
[**KasmVNC Base Image from LinuxServer**](https://github.com/linuxserver/docker-baseimage-kasmvnc) is licensed unther the GNU General Public License v3.0 (GPLv3). License is available [here](https://github.com/linuxserver/docker-baseimage-kasmvnc/blob/master/LICENSE)
Please ensure to comply with the terms and conditions of the licenses while using or modifying this project.
## Acknowledgments
Acknowledgments to the [KasmVNC](https://github.com/kasmtech/KasmVNC) project, [KasmVNC Base Image from LinuxServer](https://github.com/linuxserver/docker-baseimage-kasmvnc/tree/master), [mt5linux library](https://github.com/lucas-campagna/mt5linux) and any other project or individual that contributed to the realization of this project.

9
bots.env Normal file
View file

@ -0,0 +1,9 @@
MT5_LOGIN=36562757
MT5_PASSWORD=v*fa%F5m
MT5_SERVER=PUPrime-Live6
MT5_PATH=C:\Program Files\PU Prime MT5 Terminal\terminal64.exe
API_ID="28487993"
API_HASH="b14b06cec21a72b5cb2773ea782c7057"
PHONE_NUMBER=+41796600666
CHANNEL2_ID=-3908994316

13
bots/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
# Ein gemeinsames Image für mcp_server und live_signal_trader (~500 MB statt zwei)
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
# mt5linux 1.1.x nutzt Python-3.12-Syntax und hat eine andere Server-CLI.
# 0.1.9 passt zum gmag11-Server; seine uralten Pins (numpy 1.21 …) daher --no-deps.
RUN pip install --no-deps mt5linux==0.1.9
COPY *.py ./

446
bots/hm_signal_parser.py Normal file
View file

@ -0,0 +1,446 @@
"""
Parser for the HM XAUUSD Telegram channel (channel 2).
Message format (raw Telegram Web copy-paste):
HM XAUUSD:
SIGNAL ALERT
SELL XAUUSD 4145-4155
TP1: 4140
TP2: 4130
...
SL: 4165
HM XAUUSD:
TP1 hit, that's +70 pips
HM XAUUSD:
BIN AUF BREAK EVEN !!!
Key differences from channel 1 (Goldex):
- Messages are separated by the "HM XAUUSD:" sender prefix, not by dash lines
- No Date: / Message: wrapper in the raw copy-paste format
→ dates are assigned sequentially from an optional start timestamp,
or left as None when used purely from the live Telethon listener
(which always supplies real timestamps)
- "SIGNAL ALERT" header line precedes the actual signal body
- TP done: "TP1 hit, that's +N pips" (existing TP_DONE_RE already catches "hit")
- SL hit: "SL hit, that's -N pips" (existing SL_HIT_RE already catches this)
- BE: "BIN AUF BREAK EVEN" / "BIN AUF BE" (German) - NOT in base class
- Risk label at end of signal: "MID RISK" / "HIGH RISK" - stored as metadata
- REENTRY messages: skipped (treated as chatter, not a new signal)
- Informal: bare "SELL XAUUSD" / "BUY XAUUSD" with no price
"""
import re
from datetime import datetime, timezone, timedelta
from traderanalyser import (
TradeSignalAnalyzer,
TP_DONE_RE, TP_NUMBER_RE, ALL_TP_RE, PARTIAL_CLOSE_RE, CANCEL_RE, BE_MOVE_RE,
)
# Extend the base SL_HIT regex to include German break-even phrases.
# The base class catches "sl hit", "stopped out", "stop loss hit", "be hit".
# HM uses "BIN AUF BREAK EVEN", "BIN AUF BE", and "BIN ALLE AUF BE RAUS"
# (= "I've exited all at breakeven"). The ALLE/RAUS variants insert words
# between BIN and AUF/BE, so we can't rely on word-adjacency alone.
HM_SL_HIT_RE = re.compile(
r'\bsl\s*hit\b'
r'|\bstopped\s*out\b'
r'|\bstop\s*loss\s*hit\b'
r'|\bbe\s*hit\b'
r'|bin\s+auf\s+break\s*even' # BIN AUF BREAK EVEN
r'|bin\s+auf\s+be\b' # BIN AUF BE
r'|bin\s+\w+\s+auf\s+be\b' # BIN ALLE AUF BE (one word between)
r'|auf\s+be\s+raus' # AUF BE RAUS (exited at BE)
r'|alle\s+(?:\w+\s+)*auf\s+be\b' # ALLE ... AUF BE
r'|sl\s+auf\s+(?:be|break\s*even)\b' # SL AUF BE / SL AUF BREAK EVEN
r'|jetzt\s+auf\s+be\b', # JETZT AUF BE (now to BE)
re.IGNORECASE,
)
# "REENTRY" lines are channel-specific chatter - not new signals, not
# management messages. We skip them by detecting the keyword early.
REENTRY_RE = re.compile(r'\breentry\b', re.IGNORECASE)
# "RUNNING X pips" - informational, skip
RUNNING_RE = re.compile(r'\brunning\b.*\bpips\b', re.IGNORECASE)
# Risk label at the end of a signal block
RISK_LABEL_RE = re.compile(r'\b(high|mid|low)\s+risk\b', re.IGNORECASE)
class HMTradeSignalAnalyzer(TradeSignalAnalyzer):
"""Subclass of TradeSignalAnalyzer wired to the HM XAUUSD channel format.
Overrides only what differs: the file-parsing entry point (messages
delimited by "HM XAUUSD:" prefixes rather than dash separators, no Date
field), BE detection (German phrases), and REENTRY/RUNNING skipping.
Everything else - entry/SL/TP extraction, sanity checks, simulation,
analysis - is inherited unchanged.
"""
def parse_signals(self, start_date=None):
"""Parse a raw Telegram Web copy-paste file.
Because the copy-paste format carries no timestamps, synthetic dates
are assigned starting from `start_date` (default: 2026-01-01 UTC),
incrementing by 1 minute per message so the chronological sort in the
base class works correctly. When used from the live Telethon listener
(process_message called directly), real timestamps are always supplied
by Telethon and this method is never called.
"""
if start_date is None:
start_date = datetime(2026, 1, 1, tzinfo=timezone.utc)
with open(self.file_path, 'r', encoding='utf-8') as f:
content = f.read()
# Split on "HM XAUUSD:" prefix (the sender header Telegram Web inserts
# before each message). The pattern matches the prefix at the start of
# a line, optionally followed by a newline before the message body.
parts = re.split(r'(?m)^HM XAUUSD:\s*\n?', content)
parsed = []
for i, part in enumerate(parts):
msg = part.strip()
if not msg:
continue
# Skip sticker / photo placeholders
if re.match(r'^(😑\s*Sticker|🖼\s*Photo)$', msg, re.IGNORECASE):
continue
date = start_date + timedelta(minutes=i)
parsed.append((date, msg))
# Already in order (copy-paste is chronological), but sort anyway for
# consistency with the base class contract.
parsed.sort(key=lambda x: x[0])
for date, msg in parsed:
self.process_message(date, msg)
self._drop_superseded_informal_signals()
def process_message(self, date, msg):
"""Same as base class but using the extended HM regexes."""
# REENTRY / RUNNING lines are chatter - skip before any other check
if REENTRY_RE.search(msg) or RUNNING_RE.search(msg):
return None
# Extract optional risk label (stored on the signal for reference)
risk_match = RISK_LABEL_RE.search(msg)
risk_hint = risk_match.group(1).lower() if risk_match else None
# Strip the "SIGNAL ALERT" header line - it's just a preamble and
# confuses the entry-range extraction if left in.
msg_clean = re.sub(r'(?im)^\s*SIGNAL ALERT\s*$', '', msg).strip()
# HM uses "SELL XAUUSD 4145-4155" (action before pair).
# The base parser expects "XAUUSD SELL ..." so swap the order here.
msg_clean = re.sub(
r'(?i)\b(BUY|SELL)\s+(XAUUSD|GOLD|XAU|BTCUSD|BTC|XAGUSD|SILVER)\b',
r'\2 \1',
msg_clean,
)
signal = self._parse_new_trade(msg_clean, date)
if signal:
if risk_hint:
signal['risk_hint'] = risk_hint # informational metadata
self.signals.append(signal)
return signal
if not self.signals or self.signals[-1]['status'] != 'OPEN':
return None
# Management messages - use the extended HM_SL_HIT_RE
if CANCEL_RE.search(msg):
self.signals[-1]['status'] = 'CANCELLED'
self.signals[-1]['cancel_date'] = date
self.signals[-1]['cancel_reason'] = msg.strip()
return 'CANCEL'
# BE-move messages: base class BE_MOVE_RE catches English phrases;
# HM adds German ones via HM_SL_HIT_RE (which doubles as BE detector).
# Check HM-specific German BE patterns first, then fall through to
# the base class pattern for shared English phrases.
HM_BE_MOVE_RE = re.compile(
r'bin\s+auf\s+break\s*even'
r'|bin\s+auf\s+be\b'
r'|bin\s+\w+\s+auf\s+be\b'
r'|auf\s+be\s+raus'
r'|alle\s+(?:\w+\s+)*auf\s+be\b'
r'|sl\s+auf\s+(?:be|break\s*even)\b'
r'|jetzt\s+auf\s+be\b',
re.IGNORECASE,
)
if HM_BE_MOVE_RE.search(msg) or BE_MOVE_RE.search(msg):
# Record on the signal but keep it OPEN (position not closed yet)
self.signals[-1]['be_move_date'] = date
return 'BE_MOVE'
if HM_SL_HIT_RE.search(msg):
self._handle_sl_hit(date, msg)
elif ALL_TP_RE.search(msg):
self._handle_tp_report(date, msg, all_levels=True)
elif TP_NUMBER_RE.search(msg) and TP_DONE_RE.search(msg):
# "TP1 hit, that's +70 pips" → TP_NUMBER_RE finds "TP1", TP_DONE_RE finds "hit"
self._handle_tp_report(date, msg)
elif PARTIAL_CLOSE_RE.search(msg):
self._handle_partial_close(date, msg)
return None
def _handle_sl_hit(self, date, msg):
"""Override to also recognize German BE phrases as breakeven exits."""
sig = self.signals[-1]
sig['status'] = 'CLOSED'
# Reuse HM_SL_HIT_RE but distinguish BE vs SL close type.
# Any message that reaches here already matched HM_SL_HIT_RE.
# Classify as BE if the message mentions BE/break-even (not a plain SL).
be_hit = bool(re.search(
r'\bbe\s*hit\b'
r'|bin\s+auf\s+break\s*even'
r'|bin\s+auf\s+be\b'
r'|bin\s+\w+\s+auf\s+be\b'
r'|auf\s+be\s+raus'
r'|alle\s+(?:\w+\s+)*auf\s+be\b'
r'|sl\s+auf\s+(?:be|break\s*even)\b'
r'|jetzt\s+auf\s+be\b',
msg, re.IGNORECASE,
))
sig['reported_close_type'] = 'BE' if be_hit else 'SL'
sig['reported_close_date'] = date
def _fetch_from_telegram(analyzer, channel_id, days):
"""Pull `days` days of history from a Telegram channel and feed each
message through analyzer.process_message(). Returns (total_msgs, skipped).
Requires the same TELEGRAM_API_ID / TELEGRAM_API_HASH / PHONE_NUMBER env
vars as live_signal_trader.py. Uses a temporary session file so it never
conflicts with the live trader's session.
"""
import os
from dotenv import load_dotenv
load_dotenv()
from datetime import timedelta
from telethon.sync import TelegramClient
api_id = int(os.environ['API_ID'])
api_hash = os.environ['API_HASH']
phone = os.environ['PHONE_NUMBER']
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
print(f"Connecting to Telegram, fetching {days} days of history "
f"(since {cutoff.date()}) from channel {channel_id} …")
total_msgs = 0
skipped = 0
# Reuse the live trader's existing session so no re-authentication is needed.
session_name = 'mysession'
with TelegramClient(session_name, api_id, api_hash) as client:
client.start(phone=phone)
# Resolve the channel by walking iter_dialogs() — this gives us the
# fully resolved entity (with access hash) regardless of session cache.
# Telegram stores channel IDs as positive ints internally; the -100
# prefix is only a convention in client-facing representations.
bare_id = abs(channel_id)
if str(bare_id).startswith('100') and len(str(bare_id)) > 10:
bare_id = int(str(bare_id)[3:])
entity = None
print("Resolving channel from dialogs…")
for dialog in client.iter_dialogs():
if getattr(dialog.entity, 'id', None) == bare_id:
entity = dialog.entity
break
if entity is None:
raise ValueError(
f"Channel {channel_id} not found in your dialogs. "
"Make sure you are a member of the channel."
)
for msg in client.iter_messages(entity, reverse=True, offset_date=cutoff):
text = (msg.text or '').strip()
if not text:
skipped += 1
continue
total_msgs += 1
analyzer.process_message(msg.date, text)
analyzer._drop_superseded_informal_signals()
return total_msgs, skipped
def main():
"""Standalone backtester for the HM channel export.
File mode (default):
python3 hm_signal_parser.py <file> [mt5_url] [tier] [compound] [capital] [sizing]
Telegram mode (live history fetch – no file needed):
python3 hm_signal_parser.py --telegram [channel_id] [days] [mt5_url] [tier] [compound] [capital] [sizing]
In Telegram mode the script connects using the same TELEGRAM_API_ID /
TELEGRAM_API_HASH / PHONE_NUMBER env vars as the live trader, fetches the
last N days of history from the HM channel, feeds every message through the
parser, then runs the full backtest analysis — identical output to file mode.
channel_id defaults to the CHANNEL2_ID env var (the HM channel).
days defaults to 5.
"""
import sys
import os
from traderanalyser import RISK_TIERS, RISK_PCT_TIERS, MIN_LOT
# ── Telegram mode ────────────────────────────────────────────────────────
if len(sys.argv) > 1 and sys.argv[1] == '--telegram':
from dotenv import load_dotenv
load_dotenv()
argv = sys.argv[2:] # strip the flag
channel_id = int(argv[0]) if len(argv) > 0 else int(os.environ.get('CHANNEL2_ID', 0))
days = int(argv[1]) if len(argv) > 1 else 5
mt5_base_url = argv[2] if len(argv) > 2 else 'http://localhost:8000'
chosen_tier = argv[3] if len(argv) > 3 else 'mid'
compounding = argv[4].lower() in ('1', 'true', 'yes', 'compound') if len(argv) > 4 else False
initial_capital = float(argv[5]) if len(argv) > 5 else 100
sizing_mode = argv[6] if len(argv) > 6 else 'survival'
if not channel_id:
print("ERROR: channel_id required (pass as arg or set CHANNEL2_ID env var).")
sys.exit(1)
# Use a placeholder path – the file is never opened in Telegram mode.
analyzer = HMTradeSignalAnalyzer(
file_path='<telegram>',
initial_capital=initial_capital,
compounding=compounding,
mt5_base_url=mt5_base_url,
)
total_msgs, skipped = _fetch_from_telegram(analyzer, channel_id, days)
print(f"Fetched {total_msgs} messages ({skipped} empty/media skipped) "
f"from channel {channel_id} over the last {days} days.")
# ── File mode ────────────────────────────────────────────────────────────
else:
file_path = sys.argv[1] if len(sys.argv) > 1 else 'hm_messages.txt'
mt5_base_url = sys.argv[2] if len(sys.argv) > 2 else 'http://localhost:8000'
chosen_tier = sys.argv[3] if len(sys.argv) > 3 else 'mid'
compounding = sys.argv[4].lower() in ('1', 'true', 'yes', 'compound') if len(sys.argv) > 4 else False
initial_capital = float(sys.argv[5]) if len(sys.argv) > 5 else 100
sizing_mode = sys.argv[6] if len(sys.argv) > 6 else 'survival'
analyzer = HMTradeSignalAnalyzer(
file_path, initial_capital=initial_capital,
compounding=compounding, mt5_base_url=mt5_base_url,
)
analyzer.parse_signals()
if not analyzer.signals:
print("No signals found.")
return
if analyzer.skipped_signals:
print(f"\n{len(analyzer.skipped_signals)} signal(s) skipped by sanity check:")
for date, pair, entry, reason in analyzer.skipped_signals:
print(f" {date} {pair} entry={entry} ({reason})")
n_informal = sum(1 for s in analyzer.signals if s['is_informal'])
print(f"\n{len(analyzer.signals)} signals found ({n_informal} informal)")
# Show risk hint distribution
hints = [s.get('risk_hint') for s in analyzer.signals if s.get('risk_hint')]
if hints:
from collections import Counter
print(f"Risk hints in signals: {dict(Counter(hints))}")
analyzer.fetch_forex_data()
if analyzer.defaulted_signals:
for date, pair, action, field, note in analyzer.defaulted_signals:
print(f" {date} {pair} {action}: {note}")
mode_label = 'COMPOUNDING' if compounding else 'FIXED'
print(f"\nRisk tier comparison [{mode_label}]:")
print("-" * 70)
print(" FIXED table:")
for tier in ('high', 'mid', 'low'):
df = analyzer.analyze_trades(risk_level=tier, sizing_mode='fixed')
evaluated = df[df['result'] != 'NO_DATA']
final = df['capital'].iloc[-1] if not df.empty else initial_capital
ret = (final - initial_capital) / initial_capital * 100
wr = evaluated['pnl'].gt(0).mean() * 100 if not evaluated.empty else 0.0
print(f" {tier:>5}: final {final:,.2f} USDT return {ret:+.1f}% "
f"max dd {df['drawdown'].max():.1f}% win rate {wr:.1f}%")
print(" RISK_PCT table:")
for tier in ('high', 'mid', 'low'):
df = analyzer.analyze_trades(risk_level=tier, sizing_mode='risk_pct')
evaluated = df[df['result'] != 'NO_DATA']
final = df['capital'].iloc[-1] if not df.empty else initial_capital
ret = (final - initial_capital) / initial_capital * 100
wr = evaluated['pnl'].gt(0).mean() * 100 if not evaluated.empty else 0.0
print(f" {tier:>5} ({RISK_PCT_TIERS[tier]*100:.1f}%/trade): final {final:,.2f} USDT "
f"return {ret:+.1f}% max dd {df['drawdown'].max():.1f}% win rate {wr:.1f}%")
print(" SURVIVAL table (1/3 risk, TP1-only fallback when lot < 0.01):")
df_surv = analyzer.analyze_trades(sizing_mode='survival', compounding=True)
evaluated_surv = df_surv[df_surv['result'] != 'NO_DATA']
final_surv = df_surv['capital'].iloc[-1] if not df_surv.empty else initial_capital
ret_surv = (final_surv - initial_capital) / initial_capital * 100
wr_surv = evaluated_surv['pnl'].gt(0).mean() * 100 if not evaluated_surv.empty else 0.0
n_tp1 = (df_surv['sizing'].str.startswith('survival/TP1')).sum() if 'sizing' in df_surv.columns else '?'
print(f" (compounding): final {final_surv:,.2f} USDT return {ret_surv:+.1f}% "
f"max dd {df_surv['drawdown'].max():.1f}% win rate {wr_surv:.1f}% "
f"({n_tp1} trades in TP1-only fallback)")
results_df = analyzer.analyze_trades(
risk_level=chosen_tier, sizing_mode=sizing_mode, compounding=compounding,
)
if results_df.empty or 'capital' not in results_df.columns:
print("No trades could be evaluated.")
return
print(f"\nDetailed results — '{chosen_tier}' tier, '{sizing_mode}' sizing:")
print("-" * 50)
final = results_df['capital'].iloc[-1]
print(f"Initial capital: {initial_capital} USDT")
print(f"Final capital: {final:.2f} USDT")
print(f"Total return: {(final - initial_capital) / initial_capital * 100:.2f}%")
print(f"Max drawdown: {results_df['drawdown'].max():.2f}%")
evaluated = results_df[results_df['result'] != 'NO_DATA']
print(f"Total signals: {len(results_df)} (no data: {(results_df['result'] == 'NO_DATA').sum()})")
if not evaluated.empty:
print(f"Win rate: {evaluated['pnl'].gt(0).mean()*100:.2f}%")
print(f"Best trade: {evaluated['pnl'].max():.2f} USDT")
print(f"Worst trade: {evaluated['pnl'].min():.2f} USDT")
print(f"Avg pnl/trade: {evaluated['pnl'].mean():.2f} USDT")
results_df.to_csv('hm_trading_results.csv', index=False)
print("\nFull per-trade results saved to hm_trading_results.csv")
base_cols = ['date', 'pair', 'action', 'entry', 'signal_type', 'lot',
'reported_result', 'result', 'pnl', 'pnl_pct', 'capital']
extra = ['sizing'] if sizing_mode == 'survival' and 'sizing' in results_df.columns else []
cols = [c for c in base_cols + extra if c in results_df.columns]
display_df = results_df[cols].copy()
if 'lot' in display_df.columns:
display_df['lot'] = display_df['lot'].round(4)
display_df['pnl'] = display_df['pnl'].round(2)
display_df['pnl_pct'] = display_df['pnl_pct'].round(2)
display_df['capital'] = display_df['capital'].round(2)
print("\nDetailed trade results:")
print(display_df.to_string())
if __name__ == '__main__':
main()

1215
bots/live_signal_trader.py Normal file

File diff suppressed because it is too large Load diff

1095
bots/mcp_server.py Normal file

File diff suppressed because it is too large Load diff

12
bots/requirements.txt Normal file
View file

@ -0,0 +1,12 @@
# MCP-Server
fastapi
uvicorn
pydantic
# mt5linux wird im Dockerfile separat (0.1.9, --no-deps) installiert
rpyc>=6,<7 # muss zur rpyc-Version im MT5-Container passen
pandas
numpy
# Live-Trader
requests
telethon
python-dotenv

1059
bots/traderanalyser.py Normal file

File diff suppressed because it is too large Load diff

6
charts/mt5/Chart.yaml Normal file
View file

@ -0,0 +1,6 @@
apiVersion: v2
name: mt5
description: MetaTrader 5 mit KasmVNC-Webzugriff und RPyC (mt5linux) auf k3s
type: application
version: 0.1.0
appVersion: "latest"

View file

@ -0,0 +1,32 @@
{{- define "mt5.fullname" -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- define "mt5.labels" -}}
app.kubernetes.io/name: mt5
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Values.image.tag | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version }}
{{- end -}}
{{- define "mt5.selectorLabels" -}}
app.kubernetes.io/name: mt5
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "mt5.secretName" -}}
{{- if .Values.auth.existingSecret -}}
{{ .Values.auth.existingSecret }}
{{- else -}}
{{ include "mt5.fullname" . }}-auth
{{- end -}}
{{- end -}}
{{- define "mt5.claimName" -}}
{{- if .Values.persistence.existingClaim -}}
{{ .Values.persistence.existingClaim }}
{{- else -}}
{{ include "mt5.fullname" . }}-config
{{- end -}}
{{- end -}}

View file

@ -0,0 +1,94 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "mt5.fullname" . }}
labels:
{{- include "mt5.labels" . | nindent 4 }}
spec:
replicas: 1
# RWO-Volume + Wine-Prefix: nie zwei Pods gleichzeitig
strategy:
type: Recreate
selector:
matchLabels:
{{- include "mt5.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "mt5.selectorLabels" . | nindent 8 }}
annotations:
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
spec:
# Pod ist lange beschäftigt beim Herunterfahren von Wine
terminationGracePeriodSeconds: 60
containers:
- name: mt5
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
envFrom:
- secretRef:
name: {{ include "mt5.secretName" . }}
env:
- name: TZ
value: {{ .Values.tz | quote }}
- name: PUID
value: {{ .Values.puid | quote }}
- name: PGID
value: {{ .Values.pgid | quote }}
{{- if .Values.mt5CmdOptions }}
- name: MT5_CMD_OPTIONS
value: {{ .Values.mt5CmdOptions | quote }}
{{- end }}
ports:
- name: web
containerPort: 3000
protocol: TCP
- name: rpyc
containerPort: 8001
protocol: TCP
# Erststart installiert Wine/MT5 (~5 Min) – bis 15 Min Zeit geben
startupProbe:
tcpSocket:
port: web
periodSeconds: 10
failureThreshold: 90
readinessProbe:
tcpSocket:
port: web
periodSeconds: 15
livenessProbe:
tcpSocket:
port: web
periodSeconds: 30
failureThreshold: 5
volumeMounts:
- name: config
mountPath: /config
- name: dshm
mountPath: /dev/shm
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumes:
- name: config
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ include "mt5.claimName" . }}
{{- else }}
emptyDir: {}
{{- end }}
- name: dshm
emptyDir:
medium: Memory
sizeLimit: {{ .Values.shmSize }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}

View file

@ -0,0 +1,32 @@
{{- if and .Values.ingress.enabled .Values.ingress.host }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "mt5.fullname" . }}
labels:
{{- include "mt5.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
# Nur Web-UI (KasmVNC, inkl. WebSocket) – RPyC bleibt clusterintern
- path: /
pathType: Prefix
backend:
service:
name: {{ include "mt5.fullname" . }}
port:
name: web
{{- end }}

32
charts/mt5/templates/nano Normal file
View file

@ -0,0 +1,32 @@
{{- if and .Values.ingress.enabled .Values.ingress.host }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "mt5.fullname" . }}
labels:
{{- include "mt5.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
- host: {{ .Values.ingress.host | quote }}
http:
paths:
# Nur Web-UI (KasmVNC, inkl. WebSocket) – RPyC bleibt clusterintern
- path: /
pathType: Prefix
backend:
service:
name: {{ include "mt5.fullname" . }}
port:
name: web
{{- end }}

View file

@ -0,0 +1,20 @@
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "mt5.claimName" . }}
labels:
{{- include "mt5.labels" . | nindent 4 }}
annotations:
# PVC bei helm uninstall behalten – sonst ist Wine-Prefix + Login weg
helm.sh/resource-policy: keep
spec:
accessModes:
- {{ .Values.persistence.accessMode }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- end }}

View file

@ -0,0 +1,15 @@
{{- if not .Values.auth.existingSecret }}
{{- if not .Values.auth.password }}
{{- fail "auth.password muss gesetzt sein (oder auth.existingSecret verwenden)" }}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "mt5.secretName" . }}
labels:
{{- include "mt5.labels" . | nindent 4 }}
type: Opaque
stringData:
CUSTOM_USER: {{ .Values.auth.user | quote }}
PASSWORD: {{ .Values.auth.password | quote }}
{{- end }}

View file

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "mt5.fullname" . }}
labels:
{{- include "mt5.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "mt5.selectorLabels" . | nindent 4 }}
ports:
- name: web
port: {{ .Values.service.webPort }}
targetPort: web
protocol: TCP
- name: rpyc
port: {{ .Values.service.rpycPort }}
targetPort: rpyc
protocol: TCP

63
charts/mt5/values.yaml Normal file
View file

@ -0,0 +1,63 @@
image:
# Upstream-Image (Debian, ~4 GB, inkl. Python/RPyC). "1.1" = schlanke Variante ohne Python.
# Eigenes Build aus dem ausgecheckten Repo: repository auf deine Registry setzen.
repository: gmag11/metatrader5_vnc
tag: latest
pullPolicy: IfNotPresent
# KasmVNC-Login für das Web-UI
auth:
user: trader
password: "" # wird von Terraform via set_sensitive gesetzt
existingSecret: "" # alternativ: eigenes Secret mit Keys CUSTOM_USER / PASSWORD
# Zusätzliche MT5-Kommandozeilenoptionen, z.B. "/config:C:\\tester.ini" oder "/login:12345"
mt5CmdOptions: ""
# Zeitzone für Container (linuxserver-Basis)
tz: Europe/Zurich
puid: "1000"
pgid: "1000"
service:
type: ClusterIP
webPort: 3000 # KasmVNC Web-UI
rpycPort: 8001 # mt5linux RPyC – unauthentifiziert, nur clusterintern nutzen!
# Nur das Web-UI wird über Ingress exponiert, RPyC bewusst nicht
ingress:
enabled: false
className: nginx
host: ""
# KasmVNC nutzt WebSockets – lange Timeouts, damit die Session nicht nach 60s abreißt
annotations:
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
tls: []
# - secretName: mt5-tls
# hosts: [mt5.example.ch]
persistence:
enabled: true
storageClass: local-path # k3s-Default
size: 20Gi # Wine-Prefix + MT5 + History-Daten
accessMode: ReadWriteOnce
existingClaim: ""
# Chromium/Wine/KasmVNC brauchen mehr als die Default-64MB /dev/shm
shmSize: 1Gi
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
memory: 4Gi
# MT5/Wine-Image ist nur amd64
nodeSelector:
kubernetes.io/arch: amd64
tolerations: []
affinity: {}

35
destroy-tf-force.sh Executable file
View file

@ -0,0 +1,35 @@
#!/bin/bash
# Räumt den MT5-Helm-Release hart aus dem K3s-Cluster.
#
# ./destroy-tf-force.sh -> alles weg, PVC (Wine-Prefix, MT5-Login, History) bleibt
# ./destroy-tf-force.sh --with-data -> zusätzlich PVC löschen (MT5 installiert sich neu)
set -u
RELEASE="mt5"
NAMESPACE="trading"
K="docker exec local-k3s-platform kubectl"
RESOURCES="ingress,service,deployment,statefulset,replicaset,pod,configmap,secret"
if [ "${1:-}" = "--with-data" ]; then
RESOURCES="${RESOURCES},pvc"
echo "!!! --with-data: PVC ${RELEASE}-config wird mitgelöscht"
fi
echo "1. Entferne alle physischen K3s-Ressourcen von '${RELEASE}' in '${NAMESPACE}'..."
$K delete ${RESOURCES} -l app.kubernetes.io/instance=${RELEASE} -n ${NAMESPACE} \
--ignore-not-found=true --force --grace-period=0
echo "2. Lösche das geheime Helm-Release-Protokoll aus der K3s-Datenbank..."
# Helm speichert Releases als Secrets mit dem Typ 'sh.helm.release.v1...'
$K delete secret -n ${NAMESPACE} -l owner=helm,name=${RELEASE} \
--ignore-not-found=true --force --grace-period=0
echo "3. Atempause für die Cluster-API..."
sleep 2
echo "4. Kontrolle – hier sollte nichts mehr von '${RELEASE}' stehen:"
$K get all,ingress,secret,pvc -n ${NAMESPACE} -l app.kubernetes.io/instance=${RELEASE} 2>/dev/null
$K get secret -n ${NAMESPACE} -l owner=helm,name=${RELEASE} 2>/dev/null
echo "Cluster ist absolut jungfräulich sauber!"

View file

@ -0,0 +1,14 @@
services:
mt5:
build: .
image: gmag11/metatrader5_vnc
container_name: mt5
volumes:
- mt5-config:/config
ports:
- 3000:3000
- 8001:8001
env_file:
- .env
volumes:
mt5-config:

65
docker-compose.yaml Normal file
View file

@ -0,0 +1,65 @@
services:
# 1. MetaTrader 5 (Wine + KasmVNC) – startet selbst den RPyC-Server auf 8001
mt5:
build: . # eigenes Dockerfile mit Fix
image: mt5-vnc:local # eigener Name – nicht mit dem Upstream-Image verwechseln
container_name: mt5
volumes:
- ./config:/config
ports:
- 3001:3000 # Web-UI
# 8001 (RPyC) bewusst NICHT veröffentlicht – ungeschützter Vollzugriff auf MT5
env_file:
- .env # CUSTOM_USER / PASSWORD = VNC-Login fürs Web-UI
restart: unless-stopped
healthcheck:
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/8001"]
interval: 30s
timeout: 5s
start_period: 10m # Erststart installiert Wine + MT5
retries: 5
# 2. MCP-Server (FastAPI) – spricht per RPyC mit MT5, Broker-Login aus bots.env
mt5-mcp:
build: ./bots
image: mt5-bots:local
container_name: mt5-mcp
command: ["python", "mcp_server.py", "--live", "--rpyc-host", "mt5", "--rpyc-port", "8001"]
env_file:
- bots.env
ports:
- 127.0.0.1:8000:8000 # nur lokal – hat Order-Endpunkte ohne Auth
depends_on:
mt5:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c",
"import json,urllib.request,sys; d=json.load(urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=5)); sys.exit(0 if d.get('connected') else 1)"]
interval: 30s
timeout: 10s
start_period: 2m
retries: 3
# 3. Telegram Live-Trader – handelt über den MCP-Server
live-trader:
image: mt5-bots:local
container_name: live-trader
working_dir: /data # Session + diagnostics_*.json landen im Volume
# Nur Kanal 2 (HM XAUUSD) – CHANNEL2_ID kommt aus bots.env
command: >
python /app/live_signal_trader.py --live
--channels ch1
--risk-tier low
--mt5-base-url http://mt5-mcp:8000
--diagnostics-dir /data
env_file:
- bots.env
environment:
- TG_SESSION=/data/mysession
volumes:
- ./trader-data:/data
depends_on:
mt5-mcp:
condition: service_healthy
restart: unless-stopped

105
main.tf Normal file
View file

@ -0,0 +1,105 @@
terraform {
required_providers {
kubernetes = {
source = "hashicorp/kubernetes"
version = "~> 2.26"
}
helm = {
source = "hashicorp/helm"
version = "~> 2.12"
}
}
# Eigener State-Pfad, damit sich MT5 und die Webseite nicht in die Quere kommen
backend "consul" {
address = "consul:8500"
path = "terraform/states/mt5_tf"
}
}
# --- Variablen ---------------------------------------------------------------
variable "app_version" {
type = string
default = "latest"
}
variable "image_repository" {
type = string
default = "gmag11/metatrader5_vnc"
}
variable "vnc_user" {
type = string
default = "trader"
}
# Kommt aus TF_VAR_vnc_password (Secret MT5_VNC_PASSWORD)
variable "vnc_password" {
type = string
sensitive = true
}
variable "ingress_host" {
type = string
default = ""
}
# --- Provider ----------------------------------------------------------------
provider "kubernetes" {
config_path = "./kind_kubeconfig"
host = "https://local-k3s-platform:6443"
insecure = true
}
provider "helm" {
kubernetes {
config_path = "./kind_kubeconfig"
host = "https://local-k3s-platform:6443"
insecure = true
}
}
# --- MetaTrader 5 Deployment -------------------------------------------------
resource "helm_release" "mt5" {
name = "mt5"
chart = "./charts/mt5"
namespace = "trading"
create_namespace = true
# Erster Start installiert Wine + MT5 nach /config – das dauert ein paar Minuten
timeout = 900
wait = true
set {
name = "image.repository"
value = var.image_repository
}
set {
name = "image.tag"
value = var.app_version
}
set {
name = "auth.user"
value = var.vnc_user
}
set_sensitive {
name = "auth.password"
value = var.vnc_password
}
set {
name = "ingress.enabled"
value = var.ingress_host != "" ? "true" : "false"
}
set {
name = "ingress.host"
value = var.ingress_host
}
}

1
reg_tele_dry.sh Executable file
View file

@ -0,0 +1 @@
docker compose run --rm -it --no-deps live-trader python /app/live_signal_trader.py --dry --channels ch2

1
root/defaults/autostart Normal file
View file

@ -0,0 +1 @@
/Metatrader/start.sh

7
root/defaults/menu.xml Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<openbox_menu xmlns="http://openbox.org/3.4/menu">
<menu id="root-menu" label="MENU">
<item label="xterm" icon="/usr/share/pixmaps/xterm-color_48x48.xpm"><action name="Execute"><command>/usr/bin/xterm</command></action></item>
<item label="Metatrader 5" icon="/config/.wine/drive_c/Program Files/MetaTrader 5/Terminal.ico"><action name="Execute"><command>/usr/bin/wine "/config/.wine/drive_c/Program Files/MetaTrader 5/terminal64.exe"</command></action></item>
</menu>
</openbox_menu>