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:
+297
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
core/mt5data.py — MT5Data
|
||||
===========================
|
||||
Daten-Adapter: holt Ticks, Bars, Kontoinfo und Multi-Timeframe-Analyse aus MT5.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import MetaTrader5 as mt5
|
||||
|
||||
from core.config import (
|
||||
SYMBOL_CANDIDATES, CHART_BARS, ANGLE_LR_BARS,
|
||||
)
|
||||
from core.mt5_utils import mt5_lock
|
||||
from core.analysis import (calc_trend_angle, calc_rsi, calc_atr,
|
||||
M15Analyzer, SRDetector)
|
||||
from core.logger import get_logger
|
||||
|
||||
log_mt5 = get_logger("mt5")
|
||||
|
||||
|
||||
class MT5Data:
|
||||
def __init__(self):
|
||||
self.symbol = None
|
||||
self.bid = self.ask = self.spread = None
|
||||
self.change = self.pct = self.day_high = self.day_low = None
|
||||
self.balance = self.equity = None
|
||||
self.currency = "USD"; self.error = None
|
||||
self.trend_angle = 90.0
|
||||
self.angles = {"M5": 90.0, "M15": 90.0, "M30": 90.0, "H1": 90.0}
|
||||
self._angle_ema: dict = {} # geglättete Winkel (EMA α=0.4)
|
||||
self.rsi_m15: float | None = None
|
||||
self.atr_m15: float | None = None
|
||||
self.server_time: datetime | None = None
|
||||
self.tick_local_ts: float = 0.0
|
||||
self.tick_server_ts: int = 0
|
||||
self._slow_price_ts: float = 0.0 # letzter D1-/Konto-Fetch (Throttle)
|
||||
self.sessions: dict = {}
|
||||
self._lock = threading.Lock()
|
||||
self._connected = False
|
||||
self._bad_tick_streak: int = 0
|
||||
self.BAD_TICK_LIMIT: int = 10
|
||||
self.analyzer = None; self.sr = None
|
||||
self._analyze_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="analyzer")
|
||||
|
||||
def connect(self, preferred_symbol: str | None = None) -> bool:
|
||||
if not mt5.initialize():
|
||||
with self._lock: self.error = f"MT5: {mt5.last_error()}"; return False
|
||||
acc = mt5.account_info()
|
||||
if not acc:
|
||||
with self._lock: self.error = "MT5 nicht eingeloggt"; return False
|
||||
pref = (preferred_symbol or "").strip()
|
||||
candidates = ([pref] if pref else []) + \
|
||||
[s.strip() for s in SYMBOL_CANDIDATES if s.strip() != pref]
|
||||
for sym in candidates:
|
||||
if not sym: continue
|
||||
if mt5.symbol_info(sym) is not None:
|
||||
mt5.symbol_select(sym, True)
|
||||
with self._lock:
|
||||
self.symbol = sym; self.currency = acc.currency
|
||||
self._connected = True
|
||||
self.analyzer = M15Analyzer(sym)
|
||||
self.sr = SRDetector(sym)
|
||||
log_mt5.info(f"Konto {acc.login} | {acc.currency} | Symbol: {sym}")
|
||||
self._load_sessions(sym)
|
||||
return True
|
||||
with self._lock: self.error = "Kein WTI-/SpotCrude-Symbol"; return False
|
||||
|
||||
def switch_symbol(self, new_sym: str) -> tuple[bool, str]:
|
||||
new_sym = (new_sym or "").strip()
|
||||
if not new_sym:
|
||||
return False, "Symbol-Name leer"
|
||||
try:
|
||||
with mt5_lock(timeout=10) as got:
|
||||
if not got:
|
||||
return False, "MT5-Lock belegt"
|
||||
info = mt5.symbol_info(new_sym)
|
||||
if info is None:
|
||||
return False, f"Symbol '{new_sym}' nicht beim Broker"
|
||||
if not mt5.symbol_select(new_sym, True):
|
||||
return False, f"symbol_select('{new_sym}') fehlgeschlagen"
|
||||
new_analyzer = M15Analyzer(new_sym)
|
||||
new_sr = SRDetector(new_sym)
|
||||
with self._lock:
|
||||
self.symbol = new_sym; self.analyzer = new_analyzer; self.sr = new_sr
|
||||
self.m15_bars = []; self.ema_fast = []; self.ema_slow = []
|
||||
self.trend_angle = 90.0
|
||||
self.angles = {"M5": 90.0, "M15": 90.0, "M30": 90.0, "H1": 90.0}
|
||||
self.rsi_m15 = None; self.atr_m15 = None
|
||||
self.coc = None; self.sma50_h1 = None; self.vwap = None
|
||||
self.error = None; self._bad_tick_streak = 0
|
||||
self._load_sessions(new_sym)
|
||||
log_mt5.info(f"Symbol gewechselt: {new_sym}")
|
||||
return True, f"Symbol jetzt: {new_sym}"
|
||||
except Exception as e:
|
||||
log_mt5.error(f"switch_symbol({new_sym}): {e}", exc_info=True)
|
||||
return False, f"Fehler: {e}"
|
||||
|
||||
# Fallback-Sessionsplan für Rohöl (UTC) — greift wenn MT5 keine Sessions liefert.
|
||||
# Mon–Fr 00:00–22:00, So ab 22:00. Broker-Zeiten können minimal abweichen.
|
||||
_OIL_SESSIONS_UTC = {
|
||||
0: [(79200, 86400)], # So 22:00–24:00
|
||||
1: [(0, 79200)], # Mo 00:00–22:00
|
||||
2: [(0, 79200)], # Di
|
||||
3: [(0, 79200)], # Mi
|
||||
4: [(0, 79200)], # Do
|
||||
5: [(0, 79200)], # Fr 00:00–22:00
|
||||
6: [], # Sa geschlossen
|
||||
}
|
||||
|
||||
def _load_sessions(self, sym: str):
|
||||
sessions = {}
|
||||
api_ok = False
|
||||
for day in range(7):
|
||||
try:
|
||||
raw = mt5.symbol_info_sessions_trade(sym, day)
|
||||
if raw is not None:
|
||||
sessions[day] = [(int(s.from_), int(s.to)) for s in raw]
|
||||
api_ok = True
|
||||
else:
|
||||
sessions[day] = []
|
||||
except Exception:
|
||||
sessions[day] = []
|
||||
if not api_ok:
|
||||
sessions = dict(self._OIL_SESSIONS_UTC)
|
||||
log_mt5.debug("Sessions-API nicht verfügbar — Fallback auf Öl-Standard (UTC)")
|
||||
with self._lock:
|
||||
self.sessions = sessions
|
||||
|
||||
def reconnect(self) -> bool:
|
||||
# ALLE MT5-Calls (account_info/shutdown/initialize) MÜSSEN unter dem
|
||||
# globalen Lock laufen — sonst racet ein shutdown()/initialize() gegen
|
||||
# copy_rates/positions_get der anderen Loops (MT5-Lib ist nicht
|
||||
# thread-safe → Crash/Garbage). reconnect() wird stets OHNE gehaltenen
|
||||
# Lock aufgerufen (aus fetch_price/fetch_trend vor deren with-Block).
|
||||
with mt5_lock(timeout=10) as got:
|
||||
if not got:
|
||||
with self._lock: self.error = "Reconnect: MT5-Lock belegt"
|
||||
return False
|
||||
try:
|
||||
if mt5.account_info():
|
||||
with self._lock: self._connected = True; self.error = None
|
||||
log_mt5.info("MT5 noch verbunden — kein Hard-Reconnect nötig")
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
log_mt5.info("MT5 Hard-Reconnect …")
|
||||
try: mt5.shutdown()
|
||||
except Exception: pass
|
||||
if not mt5.initialize():
|
||||
with self._lock:
|
||||
self.error = f"Reconnect fehlgeschlagen: {mt5.last_error()}"
|
||||
self._connected = False
|
||||
return False
|
||||
if not mt5.account_info():
|
||||
with self._lock:
|
||||
self.error = "MT5 nach Reconnect nicht eingeloggt"; self._connected = False
|
||||
return False
|
||||
with self._lock: self._connected = True; self.error = None
|
||||
log_mt5.info("MT5-Reconnect erfolgreich")
|
||||
return True
|
||||
|
||||
def fetch_price(self):
|
||||
if not self._connected:
|
||||
self.reconnect(); return
|
||||
with mt5_lock() as got:
|
||||
if not got: return
|
||||
self._fetch_price_locked()
|
||||
|
||||
def _fetch_price_locked(self):
|
||||
# Hot-Path (500 ms): nur den Tick lesen — minimale Lock-Haltezeit,
|
||||
# damit Bid/Ask (und damit die live-P&L) auch unter Lock-Konkurrenz
|
||||
# (Trailing-order_send, fetch_trend) frisch bleiben.
|
||||
sym = self.symbol
|
||||
tick = mt5.symbol_info_tick(sym)
|
||||
if not tick or tick.bid <= 0:
|
||||
with self._lock:
|
||||
self._bad_tick_streak += 1; self.error = f"Kein Tick: {sym}"
|
||||
if self._bad_tick_streak >= self.BAD_TICK_LIMIT:
|
||||
self._connected = False; self._bad_tick_streak = 0
|
||||
log_mt5.warning(f"{self.BAD_TICK_LIMIT} Bad-Ticks → reconnect")
|
||||
return
|
||||
bid = float(tick.bid); ask = float(tick.ask); mid = (bid + ask) / 2
|
||||
spread = round(ask - bid, 5)
|
||||
srv_time = datetime.fromtimestamp(tick.time, tz=timezone.utc)
|
||||
|
||||
# Cold-Path (alle ~2 s): D1-Bars (Change/Tageshoch/-tief) + Kontoinfo.
|
||||
# Nicht P&L-kritisch → seltener holen, hält den Lock kürzer frei.
|
||||
now = time.time()
|
||||
do_slow = (now - self._slow_price_ts) > 2.0
|
||||
prev = dh = dl = acc = None
|
||||
if do_slow:
|
||||
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_D1, 0, 2)
|
||||
if bars is not None and len(bars) >= 2:
|
||||
prev = float(bars[0]["close"])
|
||||
dh = float(bars[1]["high"]); dl = float(bars[1]["low"])
|
||||
acc = mt5.account_info()
|
||||
self._slow_price_ts = now
|
||||
|
||||
with self._lock:
|
||||
self._bad_tick_streak = 0
|
||||
self.bid = bid; self.ask = ask; self.spread = spread
|
||||
_prev_srv = self.server_time
|
||||
if do_slow:
|
||||
if prev:
|
||||
self.change = mid - prev
|
||||
self.pct = (self.change / prev * 100) if prev else None
|
||||
self.day_high = dh if dh is not None else self.day_high
|
||||
self.day_low = dl if dl is not None else self.day_low
|
||||
if acc:
|
||||
self.balance = float(acc.balance)
|
||||
self.equity = float(acc.equity)
|
||||
self.currency = acc.currency
|
||||
prev_srv_ts = int(_prev_srv.timestamp()) if _prev_srv else 0
|
||||
tick_advanced = int(tick.time) > prev_srv_ts
|
||||
self.server_time = srv_time
|
||||
self.tick_server_ts = int(tick.time) # echter Broker-Timestamp
|
||||
if tick_advanced: self.tick_local_ts = now
|
||||
self.error = None
|
||||
|
||||
def fetch_trend(self):
|
||||
if not self._connected:
|
||||
self.reconnect(); return
|
||||
with mt5_lock() as got:
|
||||
if not got: return
|
||||
self._fetch_trend_locked()
|
||||
|
||||
def _fetch_trend_locked(self):
|
||||
# Nur noch die tatsächlich konsumierten Werte berechnen:
|
||||
# Trendwinkel (Trailing/Reversal), RSI/ATR (Auto-Trader/Logging),
|
||||
# M15-Analyzer (Reversal) und S/R (Trailing). Die früheren
|
||||
# ICT-/Chart-Berechnungen (Fib, BOS, FVG, OB, Ichimoku, VWAP, EMAs …)
|
||||
# fütterten nur die entfernte Empfehlungs-Engine.
|
||||
sym = self.symbol
|
||||
needed = CHART_BARS + 20
|
||||
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M15, 0, needed)
|
||||
if bars is not None and len(bars) >= CHART_BARS:
|
||||
closes = [float(b["close"]) for b in bars]
|
||||
highs = [float(b["high"]) for b in bars]
|
||||
lows = [float(b["low"]) for b in bars]
|
||||
ang = calc_trend_angle(closes)
|
||||
rsi = calc_rsi(closes, 14)
|
||||
atr = calc_atr(highs, lows, closes, 14)
|
||||
with self._lock:
|
||||
self.trend_angle = ang; self.rsi_m15 = rsi; self.atr_m15 = atr
|
||||
tf_map = [("M5", mt5.TIMEFRAME_M5, ANGLE_LR_BARS),
|
||||
("M15", mt5.TIMEFRAME_M15, ANGLE_LR_BARS),
|
||||
("M30", mt5.TIMEFRAME_M30, ANGLE_LR_BARS),
|
||||
("H1", mt5.TIMEFRAME_H1, ANGLE_LR_BARS)]
|
||||
raw_angles = {}
|
||||
for label, tf, lr in tf_map:
|
||||
b = mt5.copy_rates_from_pos(sym, tf, 0, lr + 5)
|
||||
raw_angles[label] = calc_trend_angle([float(x["close"]) for x in b], lr) \
|
||||
if b is not None and len(b) >= lr else 90.0
|
||||
# EMA-Glättung α=0.4 — dämpft Tick-Rauschen ohne echte Trendwenden zu verzögern
|
||||
alpha = 0.4
|
||||
with self._lock:
|
||||
for lbl, raw in raw_angles.items():
|
||||
prev = self._angle_ema.get(lbl, raw)
|
||||
self._angle_ema[lbl] = alpha * raw + (1 - alpha) * prev
|
||||
self.angles = dict(self._angle_ema)
|
||||
|
||||
if self.analyzer:
|
||||
_az = self.analyzer
|
||||
# analyze() läuft im eigenen Thread → MUSS den globalen mt5_lock selbst
|
||||
# nehmen (MT5-Lib ist nicht thread-safe; sonst Race gegen alle anderen
|
||||
# MT5-Calls). Eigener Thread, kein Deadlock mit dem hier gehaltenen Lock.
|
||||
def _locked_analyze(az=_az):
|
||||
with mt5_lock(timeout=3) as got:
|
||||
if got:
|
||||
az.analyze()
|
||||
self._analyze_executor.submit(_locked_analyze)
|
||||
if self.sr: self.sr.detect() # inline unter gehaltenem Lock → ok
|
||||
|
||||
def snapshot(self) -> dict:
|
||||
with self._lock:
|
||||
d = dict(
|
||||
symbol=self.symbol, bid=self.bid, ask=self.ask, spread=self.spread,
|
||||
change=self.change, pct=self.pct, day_high=self.day_high, day_low=self.day_low,
|
||||
balance=self.balance, equity=self.equity, currency=self.currency, error=self.error,
|
||||
trend_angle=self.trend_angle,
|
||||
angles=dict(self.angles), rsi_m15=self.rsi_m15, atr_m15=self.atr_m15,
|
||||
server_time=self.server_time, tick_local_ts=self.tick_local_ts,
|
||||
tick_server_ts=self.tick_server_ts,
|
||||
sessions=dict(self.sessions),
|
||||
)
|
||||
rev, reasons = self.analyzer.snapshot() if self.analyzer else (None, [])
|
||||
d["reversal"] = rev; d["reasons"] = reasons
|
||||
d["sr"] = self.sr.snapshot() if self.sr else None
|
||||
return d
|
||||
|
||||
def disconnect(self):
|
||||
mt5.shutdown()
|
||||
Reference in New Issue
Block a user