Initial commit: Oil Trading Bot (MT5, WTI)

Headless FastAPI-Backend (server.py + core/engine.py) mit Mobile-PWA (web/),
Strategie-/Backtest-Suite und Doku. Secrets, DB, Logs und Laufzeit-State sind
via .gitignore ausgeschlossen; Config-Vorlage: oil_widget_config.ini.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Axel Hocks
2026-07-24 08:29:23 +02:00
co-authored by Claude Opus 4.8
commit 75d28827e8
104 changed files with 21059 additions and 0 deletions
+418
View File
@@ -0,0 +1,418 @@
"""
core/config.py — Globale Konstanten + Config-Loader
=====================================================
Zentrale Stelle für alle Magic-Numbers, Farben, Zeitintervalle
und das Laden der `oil_widget_config.ini`.
"""
from __future__ import annotations
import configparser
from pathlib import Path
import MetaTrader5 as mt5
from core.logger import get_logger
log = get_logger("config")
# ══════════════════════════════════════════════
# PFADE
# ══════════════════════════════════════════════
PROJECT_ROOT = Path(__file__).parent.parent
CONFIG_FILE = PROJECT_ROOT / "oil_widget_config.ini"
HISTORY_DB_FILE = PROJECT_ROOT / "oil_widget_history.db"
# ══════════════════════════════════════════════
# TRADING-KONSTANTEN
# ══════════════════════════════════════════════
SYMBOL_CANDIDATES = [
# WTI / USOIL — gehandeltes Instrument (SpotCrude). Reine WTI-Kandidatenliste.
# (Brent-Umstellung 2026-07-09 wurde am selben Tag zurückgenommen.)
"SpotCrude", "USOIL", "WTI", "XTIUSD", "WTICOUSD", "OIL.WTI", "CRUDE.WTI",
"USOilSpot", "SpotWTI", "WTISpot", "USCrude", "USOilCash",
]
MARGIN_BUFFER = 0.90 # Default; Laufzeitwert aus `[trading] margin_buffer_pct`
# (aktuell 90). Lot-Größe = ~90 % der freien Margin
# (User-Vorgabe) via `calc_lots`.
RISK_PER_TRADE = 0.015 # AKTIV: Verlust beim Initial-SL ≈ 1,5 % der Equity je
# Trade (`calc_lots_risk`). Laufzeitwert aus
# `[trading] risk_pct`. 0 = aus → Fallback margin-basiert
# (MARGIN_BUFFER). Behebt die großen EUR-Verluste
# (90 %-Margin × 2×ATR-SL) — `docs/risiko-management.md`.
DEVIATION = 30
MAGIC = 230002
def set_margin_buffer(pct: float) -> float:
"""Setzt den globalen Margin-Buffer zur Laufzeit (1-99 %)."""
global MARGIN_BUFFER
MARGIN_BUFFER = max(0.01, min(0.99, pct / 100.0))
log.info(f"Margin-Buffer geändert: {MARGIN_BUFFER*100:.0f}%")
return MARGIN_BUFFER
def get_margin_buffer() -> float:
"""Aktuellen Margin-Buffer abrufen (Module-Level globals sind Snapshot-frei)."""
return MARGIN_BUFFER
def set_risk_per_trade(pct: float) -> float:
"""Setzt das Risiko pro Trade zur Laufzeit (% der Equity). 0 = aus (Fallback
margin-basiert). Sinnvoll 0,53 %."""
global RISK_PER_TRADE
RISK_PER_TRADE = max(0.0, min(0.10, pct / 100.0))
log.info(f"Risiko/Trade geändert: {RISK_PER_TRADE*100:.2f}% "
f"({'risiko-basiert' if RISK_PER_TRADE > 0 else 'aus → margin-basiert'})")
return RISK_PER_TRADE
def get_risk_per_trade() -> float:
return RISK_PER_TRADE
# ══════════════════════════════════════════════
# STOP-LOSS / TAKE-PROFIT
# ══════════════════════════════════════════════
SL_TF = mt5.TIMEFRAME_M15
SL_LOOKBACK = 60
SL_WINDOW = 4
SL_BUFFER_TICKS = 5
INIT_SL_FALLBACK = 0.012 # 1.2 %
INIT_SL_MIN_ATR = 1.8 # Initial-SL-Distanz MIN 1.8×ATR(M15) — verhindert
# zu enge Stops, wenn der nächste Pivot direkt am
# Einstieg liegt (sonst Stop nach Sekunden auf Rauschen).
INIT_SL_MAX_ATR = 2.2 # Initial-SL-Distanz MAX 2.2×ATR(M15). Band [1.8 … 2.2]
# (Ziel 2,0): Exit-Simulation (backtest_exit.py) zeigte,
# dass 1,21,5 zu eng war (MAE ~1,7×ATR, 3137 % Früh-
# stopps); 2,0 hebt Ø-R +32 %, PF 1,33→1,39, Worst-Case
# auf 2,0×ATR begrenzt. Doku: docs/exit-simulation.md.
INIT_TP_RR = 2.0
# ══════════════════════════════════════════════
# ANALYSE
# ══════════════════════════════════════════════
M15_BARS = 60
EMA_FAST = 5
EMA_SLOW = 13
PIVOT_WINDOW = 3
ANGLE_LR_BARS = 14
SR_LOOKBACK = 150
SR_PIVOT_WIN = 4
SR_MIN_TOUCHES = 3
SR_TOL_ATR_FACTOR = 0.5
SR_MAX_LINES = 2
TL_MIN_PIVOTS = 2
# ══════════════════════════════════════════════
# ANALYSE-DATENFENSTER
# ══════════════════════════════════════════════
CHART_BARS = 50 # Bar-Fenster der M15-Analyse (Trendlinien-Mapping)
# ══════════════════════════════════════════════
# TIMINGS
# ══════════════════════════════════════════════
PRICE_REFRESH_MS = 500
TREND_REFRESH_MS = 5_000
POS_REFRESH_MS = 1_000
BLINK_INTERVAL_MS = 380
BLINK_COUNT_MAX = 3
AUTO_BTN_ON_BG = "#1f6feb" # Blau AUTO-Button aktiv
AUTO_BTN_ON_FG = "#ffffff"
AUTO_BTN_ON_ACT = "#3081f0"
AUTO_BTN_OFF_BG = "#1a1e24"
AUTO_BTN_OFF_FG = "#8b949e"
WIDGET_X = 20
WIDGET_Y = 55
# ══════════════════════════════════════════════
# FARBEN
# ══════════════════════════════════════════════
BG = "#0d1117"
BORDER_NEUTRAL = "#30363d"
BORDER_POS = "#1a4731"
BORDER_NEG = "#3d1a1a"
BORDER_BLINK = "#1f6feb"
BLINK_BG = "#0d1f3c"
ACCENT = "#e8a000"
UP_COLOR = "#3fb950"
DOWN_COLOR = "#f85149"
SPREAD_COLOR = "#58a6ff"
TEXT_DIM = "#8b949e"
TEXT_BRIGHT = "#e6edf3"
GRID_COLOR = "#161b22"
EMA_F_COLOR = "#e8a000"
EMA_S_COLOR = "#58a6ff"
# Buttons
BTN_LONG_BG = "#1a6e35"; BTN_LONG_FG = "#ffffff"; BTN_LONG_ACT = "#26a050"
BTN_SHORT_BG = "#8b1a1a"; BTN_SHORT_FG = "#ffffff"; BTN_SHORT_ACT = "#b02020"
BTN_CLOSE_BG = "#2d2000"; BTN_CLOSE_FG = "#e8a000"; BTN_CLOSE_ACT = "#4a3600"
BTN_DIS_BG = "#1a1e24"; BTN_DIS_FG = "#3a4049"
# ══════════════════════════════════════════════
# CONFIG-FILE LOADER
# ══════════════════════════════════════════════
DEFAULT_CONFIG = {
"openai": {
"api_key": "",
"model": "gpt-4o-mini-search-preview",
"auto_refresh": "true",
"refresh_min": "60",
"search_context": "low",
},
"gemini": {
# Key gehört NUR in die lokale oil_widget_config.ini, nie in den Code
"api_key": "",
"model": "gemini-2.0-flash",
"enabled": "true",
},
"anthropic": {
# Claude-Key (sk-ant-…) — nur in die lokale config.ini, nie in den Code
"api_key": "",
"model": "claude-opus-4-8",
},
"ollama": {
# Lokales LLM via Ollama (kein Key, kein Quota, läuft offline)
"base_url": "http://localhost:11434",
"model": "qwen2.5:7b",
# Modell zwischen den Ticks geladen halten (GPU-resident, ~4,7 GB VRAM).
# "0" entlädt sofort nach jedem Aufruf, "-1" hält unbegrenzt.
"keep_alive": "30m",
},
"agent": {
"enabled": "true", # KI-Copilot automatisch laufen lassen
"provider": "local", # local (Ollama) | claude | gemini | openai | zai | kimi
"model": "", # leer = Provider-Default
"refresh_min": "5", # Intervall der Lagebeurteilung
"telegram": "false", # Beurteilung zusätzlich per Telegram pushen
},
"kimi": {
# Kimi / Moonshot AI (OpenAI-kompatibel). Key gehört in die ini (Secret).
# Endpoint = international .ai (der .cn-Endpoint akzeptiert diesen Key NICHT).
# kimi-k2.6 = Reasoning-Modell → verbraucht ~400 reasoning_tokens VOR dem
# content; _call_kimi setzt max_tokens großzügig + temperature=1 (Pflicht).
"api_key": "",
"base_url": "https://api.moonshot.ai/v1",
"model": "kimi-k2.6",
},
"deepseek": {
# DeepSeek (OpenAI-kompatibel, api.deepseek.com). deepseek-v4-flash ist ein
# Reasoning-Modell (content nach reasoning_content → max_tokens großzügig).
# ⚠ KEINE Web-Suche (rein Chat) → NICHT für daily_levels geeignet, nur Copilot.
"api_key": "",
"base_url": "https://api.deepseek.com",
"model": "deepseek-v4-flash",
},
"web": {
# Mobile-Web-Backend (server.py). Lese-Endpoints (Snapshot/WebSocket)
# sind offen; Trade-Aktionen brauchen diesen Token im X-Auth-Token-Header.
# Leer = wird beim ersten Start automatisch generiert und geloggt.
"api_token": "",
"require_confirm": "true", # Bestätigungs-Dialog vor jeder Order im UI
# Token-Pflicht für Trade-Aktionen. "false" = kein Token nötig
# (nur sinnvoll, wenn der Zugang anderweitig abgesichert ist, z.B. WireGuard).
"require_token": "true",
},
"news": {
"enabled": "true",
"refresh_min": "10",
},
"translation": {
"enabled": "true",
"target_language": "de",
"provider": "google",
"show_original": "false",
},
"telegram": {
"enabled": "false",
"bot_token": "",
"chat_id": "",
},
"trading": {
"margin_buffer_pct": "90",
# Risiko-basiertes Sizing: Verlust beim Initial-SL ≈ risk_pct % der Equity.
# 0 = aus → margin-basiert (margin_buffer_pct). Behebt große EUR-Verluste.
"risk_pct": "1.5",
"last_symbol": "",
"atr_tf": "Auto",
# Quellensteuer auf Gewinn-Trades (Broker behält pro Gewinn ein).
# DE: Abgeltungsteuer 25% + Soli 5,5% = 26.375. 0 = keine WHT.
"wht_pct": "0",
# Wer wählt den Analyse-Timeframe der Empfehlung?
# heuristic = schnelle Volatilitäts-/Trend-Heuristik (jede Minute, kein LLM)
# agent = der KI-Agent (LLM) wählt
# M1/M5/M15/M30/H1 = fest
"tf_select": "heuristic",
# Band, aus dem die Heuristik die Basis-TF wählt. Daytrading = M1M15
# (M30/H1 bleiben nur Kontext: Filter/Konfluenz). tf_min = niedrigste,
# tf_max = höchste erlaubte TF.
"tf_min": "M5",
"tf_max": "H1",
# ATR-Breakout-Bestätigung: Signal erst handeln, wenn der Kurs k×ATR in
# Signalrichtung lief (gemessen ~2× Edge/Trade). 0 = aus (Sofort-Einstieg).
"breakout_k": "1.0",
# Fixwert-Notfall-Stop = aus (0). Der frühere 2%-Auto-Arm war ~0,76×ATR = 3×
# enger als der Squeeze-SL und schüttelte validierte Ausbrüche raus (real
# 2026-07-17: 48,94 €). Seit 2026-07-20 läuft stattdessen das %-GAP-NETZ
# (auto_emergency_pct=3.0, s. u.) — nur in Kombination mit risk_pct=1.5 sinnvoll.
"auto_emergency_loss": "0",
# Gewinn-mitnehmen automatisch beim Öffnen setzen (Kontowährung). 0 = aus
# (dann nur manuell im UI). Merkt den zuletzt gesetzten Wert.
"auto_takeprofit": "0",
# Automatischer S/R-Close (gemessen `backtest_srclose_prob.py`): schließt eine
# Position im PLUS selbstständig, wenn der Kurs am gegenüberliegenden Level ist
# und das kalibrierte P(Durchbruch) unter `sr_close_pbreak` liegt (Level prallt
# wahrscheinlich ab). true/false.
"auto_sr_close": "true",
# Schwelle der User-Regel „laufen lassen wenn P(Durchbruch) ≥ X, sonst close".
# 0.60 = validiertes Optimum-Plateau (0,500,60 ~gleich). Band [0.30 … 0.90].
"sr_close_pbreak": "0.60",
# S/R-Level als CSV nach MQL5\Files schreiben (für den SR_Levels.mq5-Indikator,
# der sie im Desktop-Terminal zeichnet). true/false.
"export_mql5_levels": "true",
# Mindestgewinn (Kontowährung) für den S/R-Auto-Close: Trade wird am Level
# nur geschlossen, wenn P&L ≥ diesem Betrag. Wird bei JEDER neuen Position
# automatisch neu gesetzt, s. `sr_close_min_gain_pct` (0 dort = aus, dann bleibt
# dieser Wert manuell/persistiert maßgeblich). ⚠ Gemessen ist ein Mindestgewinn
# tendenziell SCHLECHTER als ohne (`backtest_srclose_prob.py`, Mindestgewinn-
# Sektion: verliert monoton in beiden Hälften) — bewusster User-Opt-in.
"sr_close_min_gain": "0",
# Gewinn-Close automatisch auf X % des EINSATZES (Margin) der jeweiligen Position
# setzen, sobald sie eröffnet wird (User-Vorgabe 2026-07-23, 1%→3% am selben Tag
# nachgezogen zusammen mit dem margin-%-Notfall-Stop). 0 = aus (dann bleibt
# der manuell gesetzte/gemerkte `sr_close_min_gain` unverändert maßgeblich).
"sr_close_min_gain_pct": "3.0",
# Auto-Notfall-Stop als % der Balance beim Öffnen (skaliert mit dem Konto).
# >0 → Stop = pct% × Balance; 0 = aus. ⚠ Nur zusammen mit risk_pct>0 als
# GAP-NETZ sinnvoll (dann pct ≈ 2× risk_pct → feuert nur bei Slippage/Gap
# ÜBER den SL hinaus): bei Margin-Sizing ist ein %-Stop in hoher Vola enger
# als der 2×ATR-SL und zerschießt die Squeeze-Strategie (gemessen 2026-07-17,
# 48,94-€-Tag). 2026-07-20 kurz als 3%-Netz mit risk_pct=1.5 aktiv, mit der
# Rückkehr zu Margin-Sizing (80 %) am selben Tag wieder AUS. Bleibt 0 — ersetzt
# durch `auto_emergency_margin_pct` (s. u.), hat Vorrang wenn >0.
"auto_emergency_pct": "0",
# Notfall-Stop als % der EINSATZ-Margin der Position (User-Vorgabe 2026-07-23,
# analog zu `sr_close_min_gain_pct`). >0 → Stop = pct% × Margin dieser Position,
# hat Vorrang vor `auto_emergency_pct` (Balance) und dem Fixwert. 0 = aus.
# ⚠ Gleiche Kopplungs-Warnung wie beim Balance-%-Modus: unter Margin-Sizing kann
# ein enger %-Stop schneller greifen als der 2×ATR-SL — bewusste User-Vorgabe.
"auto_emergency_margin_pct": "3.0",
# Entry-Raum-Gate: Signal → WARTEN, wenn das Gegenlevel (M5-Pivot in Trade-
# Richtung) näher als X×ATR liegt (gemessen `backtest_entryroom.py` — Raum
# <0,6 in beiden Hälften negativ: Ertrag am Level gedeckelt, Kosten fressen
# den Rest). 0 = aus.
"entry_room_atr": "0.6",
# Tageszeit-Gate: Komma-Liste der Stunden (Berlin), zu denen die Wellen-
# Empfehlung WARTEN erzwingt. **Gemessener Default = 07,12,16** (Nacht-
# Kostenfalle + 12/16 Uhr beidhälftig negativ, `backtest_hourly_split.py`).
# LEER = Gate AUS. Die ini setzt es leer (User-Vorgabe 2026-07-22, bewusst
# gegen die Messung). EIA-Blackout (Mi 15:3016:30) ist separat, bleibt aktiv.
"dead_hours": "0,1,2,3,4,5,6,7,12,16",
# Autonomer Entry auf den Squeeze-Ausbruch (echte Order, nur flat). ⚠ Default
# AUS — Autonomie nie stillschweigend an. true = Bot eröffnet selbständig.
"auto_squeeze": "false",
# Startup-Schonfrist (Sek.): nach Bot-Start schließt der Bot so lange KEINEN
# laufenden Trade selbst (S/R-Close/Notfall/Time-Stop/Reverse) — verhindert
# den Sofort-Close eines adoptierten Trades auf noch-instabilen Daten direkt
# nach restart_server.bat. Broker-SL unberührt. 0 = aus. (User-Vorgabe 2026-07-20)
"startup_close_grace_s": "60",
# Stop-&-Reverse für den Auto-Squeeze = AUS (Default, User-Vorgabe 2026-07-17):
# NUR der flat-Entry ist gemessen-validiert (2 Halbjahre, ØR +0,14…+0,23). Der
# Reverse (Gegen-Trade bei Ausbruch sofort schließen + drehen) ist UNBELEGT,
# verdoppelt Kosten und dreht am Ausbruch-Extrem (real 2026-07-17 18:55: Short
# 29,64 geschlossen, dann LONG am Erschöpfungs-Top). true = Reverse an.
"auto_squeeze_reverse": "false",
# Dead-Hour-Guard für den Auto-Squeeze (keine Nacht-Entries 07 Uhr Berlin) =
# AUS (User-Vorgabe 2026-07-17). Das live-Verlust-Argument war emergency-getrieben
# und mit dem Notfall-Stop-Ausbau moot; der gemessene Nacht-Kosten-Befund
# (`backtest_realcosts.py`, Spread÷Nacht-ATR 0,320,50) bleibt gültig → per
# B4-Wochenmonitor prüfen, ob Nacht-Squeezes tragen. Code (`_SQUEEZE_NIGHT`,
# `_squeeze_skip_night`) bleibt dormant/reaktivierbar (true = wieder an).
"auto_squeeze_skip_night": "false",
},
# (Der frühere `[setups]`-Block — Per-Setup-Toggles für den Dry-Run-Auto-Trader —
# ist entfernt, 2026-07-23: verwaist seit dem Entfernen des Dry-Run-Traders, wurde
# aber auch davor nirgends im Code gelesen. Die `[setups]`-Sektion kann in einer
# bestehenden oil_widget_config.ini stehen bleiben, wird nur nicht mehr neu erzeugt.)
# Stehende S/R-Zonen für den KI-Agenten — frei editierbar.
# Format je Eintrag: name = lo, hi, kind (kind: support | demand |
# resistance | supply | invalidation; Einzel-Level: lo == hi)
"zones": {
"demand": "74.98, 76.75, demand",
"support": "76.40, 77.00, support",
"res_low": "81.24, 82.64, resistance",
"fvg": "88.00, 90.00, resistance",
"res_top": "94.72, 94.72, resistance",
"invalid": "75.40, 75.80, invalidation",
},
}
def parse_zones(cfg) -> list[dict]:
"""Liest die [zones]-Sektion in eine Liste {name, lo, hi, kind}."""
out: list[dict] = []
try:
if not cfg.has_section("zones"):
return out
for name, val in cfg["zones"].items():
parts = [p.strip() for p in (val or "").split(",")]
if len(parts) < 2:
continue
try:
lo, hi = float(parts[0]), float(parts[1])
except ValueError:
continue
kind = parts[2].lower() if len(parts) >= 3 else "level"
if lo > hi:
lo, hi = hi, lo
out.append({"name": name, "lo": lo, "hi": hi, "kind": kind})
except Exception:
pass
return out
def load_config() -> configparser.ConfigParser:
"""Liest oil_widget_config.ini, ergänzt fehlende Defaults."""
cfg = configparser.ConfigParser()
if CONFIG_FILE.exists():
cfg.read(CONFIG_FILE, encoding="utf-8")
changed = False
for section, defaults in DEFAULT_CONFIG.items():
if section not in cfg:
cfg[section] = {}
changed = True
for k, v in defaults.items():
if k not in cfg[section]:
cfg[section][k] = v
changed = True
if changed:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
f.write("# Config für den Oil Trading Server (server.py)\n")
f.write("# Claude-Key (KI-Copilot): https://console.anthropic.com/\n\n")
cfg.write(f)
log.info(f"Config aktualisiert: {CONFIG_FILE}")
if not cfg["anthropic"]["api_key"]:
log.warning("Trage deinen Claude-Key in [anthropic] api_key ein")
return cfg
def save_config(cfg: configparser.ConfigParser):
"""Speichert die Config-Datei (nach Änderungen über das Widget)."""
try:
with open(CONFIG_FILE, "w", encoding="utf-8") as f:
cfg.write(f)
except Exception as e:
log.error(f"Config-Save fehlgeschlagen: {e}")