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
+1
View File
@@ -0,0 +1 @@
"""Core-Module für den Oil Trading Server."""
+615
View File
@@ -0,0 +1,615 @@
"""
core/agent.py — TradingAgent (Phase 1: read-only Copilot)
==========================================================
Ein KI-Copilot, der den kompletten Systemzustand liest, in Klartext
beurteilt und eine begründete Empfehlung gibt — OHNE etwas auszuführen.
Die schnellen, deterministischen Entscheidungen (Wellen-Signal, Trailing,
Emergency-Close) bleiben im Code; der Agent ist die langsame, denkende
Schicht darüber (Intervall ~5 min, nicht im Tick-Pfad).
Read-Tools (liefern vorhandene snapshot()-Methoden):
_tool_market → Wellen-Signal + TradersUnion-Tachos
_tool_position → offene Position + Live-P&L + Trailing-Phase
_tool_account → Symbol/Preis/Spread/Balance/Equity/RSI/ATR/Reversal
_tool_performance → Tages-/Wochen-Statistik + letzte Trades + Dry-Run
_tool_news → News-Sentiment
Phase 1 ruft die Tools deterministisch auf (ein LLM-Call pro Runde, schont
das Quota). Die saubere Tool-Trennung erlaubt in Phase 2 echtes
Function-Calling + Trade-Vorschläge.
Provider: lokales LLM via Ollama (Default — kein Key, kein Quota) oder Claude
(offizielles anthropic-SDK), Gemini/OpenAI als Fallback. Konfiguration in
oil_widget_config.ini ([ollama]/[anthropic]/[gemini]/[openai]).
"""
from __future__ import annotations
import json
import threading
import time
from core.logger import get_logger
from core.market_hours import session_state
log = get_logger("agent")
def _agent_session() -> dict:
"""Kompakter Session-Status für den Agent-Kontext."""
ss = session_state()
return {
"phase": ss["phase"],
"active": ss["active"],
"just_opened": ss["just_opened"],
"next_open": ss["next_open"],
}
_GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
_OPENAI_URL = "https://api.openai.com/v1/chat/completions"
_SYS = (
"Du bist ein nüchterner Trading-Copilot für WTI-Rohöl (Intraday-Scalping). "
"Du bekommst den aktuellen Systemzustand eines automatischen Handels-Bots "
"(Wellen-Signal als ATR-ZigZag, TradersUnion-Tachos, offene Position, "
"Konto, Track-Record, News). Beurteile die Lage knapp und ehrlich — keine "
"Garantien, keine Hype-Sprache. Wenn die Signale widersprüchlich oder dünn "
"sind, sag das klar und empfiehl NEUTRAL/abwarten.\n\n"
"WICHTIG — nutze den Track-Record zur Kalibrierung: 'current_setup_history' "
"zeigt, wie das aktuell anstehende Setup bisher real gelaufen ist, "
"'by_setup' die übrigen, 'week'/'today' die Gesamtbilanz. Hat das aktuelle "
"Setup eine schwache Trefferquote oder negative Durchschnitts-PnL (avg_pnl), "
"dämpfe deine Konfidenz deutlich oder empfiehl NEUTRAL — auch wenn das "
"Live-Signal stark wirkt. Hat es sich bewährt, darf das deine Konfidenz "
"stützen. Folge dem Signal nicht blind gegen eine klar negative Historie; "
"nenne den Bezug in 'reasoning' kurz (z.B. Setup-Trefferquote).\n\n"
"Wähle außerdem den Analyse-Timeframe 'timeframe' für die Wellen-Erkennung "
"(M1|M5|M15|M30|H1): M1/M5 bei enger Range und ruhigem Markt (Scalping, "
"kleine Wellen); höhere TF (M15/M30/H1) bei klarem Trend, hoher Volatilität "
"oder wenn die niedrige TF laut Track-Record zu verrauscht ist (viele "
"Fehlsignale). Nimm den Timeframe, auf dem die Welle am klarsten und "
"verlässlichsten handelbar ist. Im Zweifel M5.\n\n"
"Beziehe die Elliott-Wave-Struktur ('elliott') ein, falls vorhanden und "
"valid=true: Steht der Kurs nahe einem projizierten Wellen-5-Ziel "
"(target/1.618) oder ist 'exhaustion'=true bzw. der Impuls vollendet, ist "
"der Trend ERSCHÖPFT — sei vorsichtig mit Einstiegen in Trendrichtung und "
"rechne mit einem Reversal (spricht für NEUTRAL oder Gegenrichtung). Ein "
"offener FVG ('fvg') ist eine Reaktionszone (bullish=Support unter, "
"bearish=Widerstand über dem Kurs). Ist 'valid'=false oder die Struktur "
"unklar, ignoriere die Welle und entscheide nach dem Wellen-Signal. EW ist "
"Heuristik — nenne den Bezug in 'reasoning' nur, wenn er klar ist.\n\n"
"S/R-Level ('zones''resistances'/'supports' mit 'price' und 'dist'): das "
"sind die ECHTEN, aktuell berechneten M5-Pivot-Level (dieselben wie im Chart). "
"⚠ WICHTIG: Nenne in deiner Antwort AUSSCHLIESSLICH diese übergebenen Preise. "
"ERFINDE KEINE eigenen runden Marken (nicht '80 $'/'85 $', wenn sie nicht in "
"der Liste stehen) und runde die Level nicht. Kurs nahe einer Resistance "
"('dist' klein) → Abprall/Short möglich; nahe einem Support → Bounce/Long "
"möglich. Level sind Reaktionsbereiche, kein Selbstläufer.\n\n"
"Beachte die Börsen-Session ('session'): direkt nach einem Open "
"('just_opened' gesetzt, Frankfurt 9:00 / US 15:00) ist der Markt volatil "
"und whipsaw-anfällig — sei vorsichtiger (Konfidenz eher senken). In aktiver "
"US-/DE-Session ('active') gibt es mehr Liquidität und klarere Trends; "
"außerhalb (dünn) ist Vorsicht angebracht.\n\n"
"Antworte AUSSCHLIESSLICH mit einem JSON-Objekt in genau dieser Form "
"(deutsche Texte):\n"
"{\n"
' "bias": "LONG" | "SHORT" | "NEUTRAL",\n'
' "confidence": <0-100>,\n'
' "timeframe": "M1" | "M5" | "M15" | "M30" | "H1",\n'
' "headline": "<ein prägnanter Satz>",\n'
' "reasoning": "<2-4 Sätze Begründung>",\n'
' "risks": ["<Risiko 1>", "<Risiko 2>"],\n'
' "position_note": "<Hinweis zur offenen Position, sonst leer>"\n'
"}\n"
"Kein Markdown, keine Code-Fences, nur das JSON.\n"
"SPRACHE: Alle Texte (headline, reasoning, risks, position_note) MÜSSEN auf "
"DEUTSCH sein. Verwende ausschließlich lateinische Buchstaben — KEINE "
"chinesischen, japanischen oder kyrillischen Zeichen, kein Englisch."
)
def _has_cjk(rec: dict) -> bool:
"""True, wenn die Text-Felder chinesische/CJK-Zeichen enthalten (Modell hat die
Deutsch-Vorgabe ignoriert — v.a. bei lokalen Qwen-Modellen)."""
txt = " ".join(str(rec.get(k, "")) for k in
("headline", "reasoning", "position_note"))
txt += " ".join(str(x) for x in (rec.get("risks") or []))
return any("" <= c <= "鿿" for c in txt)
# JSON-Schema für strukturierte Ausgabe (Claude: output_config.format erzwingt es)
_SCHEMA = {
"type": "object",
"properties": {
"bias": {"type": "string", "enum": ["LONG", "SHORT", "NEUTRAL"]},
"confidence": {"type": "integer"},
"timeframe": {"type": "string",
"enum": ["M1", "M5", "M15", "M30", "H1"]},
"headline": {"type": "string"},
"reasoning": {"type": "string"},
"risks": {"type": "array", "items": {"type": "string"}},
"position_note": {"type": "string"},
},
"required": ["bias", "confidence", "timeframe", "headline", "reasoning",
"risks", "position_note"],
"additionalProperties": False,
}
class TradingAgent:
def __init__(self, cfg, *, data, trader, trail, tu, wave, history,
news, elliott=None):
self.cfg = cfg
self.data = data
self.trader = trader
self.trail = trail
self.tu = tu
self.wave = wave
self.history = history
self.news = news
self.elliott = elliott
ac = cfg["agent"] if cfg.has_section("agent") else {}
self.provider = (ac.get("provider", "local") or "local").lower()
self._model_cfg = ac.get("model", "") or ""
self.interval_min = max(1, int(ac.get("refresh_min", "5") or 5))
self.auto_enabled = (ac.get("enabled", "true") or "true").lower() == "true"
self.tg_push = (ac.get("telegram", "false") or "false").lower() == "true"
self._lock = threading.Lock()
self._busy = False
self._last: dict | None = None # letzte Beurteilung (geparst)
self._error: str | None = None
self._ts: float | None = None
# ── Provider-Konfiguration ───────────────────────────────────────────────
def _key(self, section: str) -> str:
try:
return (self.cfg[section]["api_key"] or "").strip()
except Exception:
return ""
def _anthropic_key(self) -> str:
return self._key("anthropic")
def _gemini_key(self) -> str:
return self._key("gemini")
def _openai_key(self) -> str:
return self._key("openai")
def _local_model(self) -> str:
try:
return (self.cfg["ollama"]["model"] or "").strip()
except Exception:
return ""
def _active_provider(self) -> str | None:
"""Bevorzugt den konfigurierten Provider, fällt sonst der Reihe nach
auf einen verfügbaren zurück. 'local' (Ollama) gilt als verfügbar,
sobald ein Modellname gesetzt ist — Erreichbarkeit wird erst beim
Aufruf geprüft (kein Live-Probe im häufig aufgerufenen Pfad)."""
avail = {
"local": bool(self._local_model()),
"claude": self._anthropic_key().startswith("sk-ant"),
"gemini": self._gemini_key().startswith("AIza"),
"openai": self._openai_key().startswith("sk-"),
"zai": bool(self._key("zai")),
"kimi": self._key("kimi").startswith("sk-"),
"deepseek": self._key("deepseek").startswith("sk-"),
}
default = ["local", "claude", "gemini", "openai"]
orders = {
"local": ["local", "claude", "gemini", "openai"],
"claude": ["claude", "local", "gemini", "openai"],
"gemini": ["gemini", "openai", "claude", "local"],
"openai": ["openai", "gemini", "claude", "local"],
"zai": ["zai", "kimi", "deepseek", "local"],
"kimi": ["kimi", "deepseek", "zai", "local"],
"deepseek": ["deepseek", "kimi", "zai", "local"],
}
for p in orders.get(self.provider, default):
if avail[p]:
return p
return None
def is_configured(self) -> bool:
return self._active_provider() is not None
# ── Read-Tools (lesen vorhandene Snapshots) ──────────────────────────────
def _tool_market(self, wsig: dict | None = None) -> dict:
wsig = wsig if wsig is not None else self.wave.signal()
wsnap = self.wave.snapshot()
# TU entfernt (2026-07-08): war nach der TU-Entfernung aus Empfehlung/Verdict
# die letzte Hintertür — der Copilot ist eine Verdict-Stimme, TU floss so
# indirekt wieder ein (lagging, nicht backtestbar).
return {
"wave_signal": wsig.get("signal"),
"wave_confidence": wsig.get("conf_pct"),
"wave_setup": wsig.get("setup"),
"wave_tf": wsnap.get("tf"),
"wave_direction": wsnap.get("direction"),
"wave_move_atr": wsnap.get("move_atr"),
"wave_reasons": wsig.get("reasons", [])[:4],
"session": _agent_session(),
}
def _tool_position(self) -> dict:
ps = self.trader.snapshot()
if ps.get("ticket") is None:
return {"open": False}
s = self.data.snapshot()
live = None
if s.get("bid") and s.get("ask"):
live = self.trader.live_pnl(s["bid"], s["ask"])
ts = self.trail.snapshot()
return {
"open": True,
"direction": "LONG" if ps.get("order_type") == 0 else "SHORT",
"lots": round(ps.get("lots") or 0.0, 2),
"entry": round(ps.get("entry_price") or 0.0, 3),
"pnl": round((live if live is not None else ps.get("pnl") or 0.0), 2),
"trailing": ts.get("enabled"),
"trail_phase": ts.get("phase"),
}
def _tool_account(self) -> dict:
s = self.data.snapshot()
return {
"symbol": s.get("symbol"),
"bid": s.get("bid"), "ask": s.get("ask"),
"spread": s.get("spread"),
"change": s.get("change"), "pct": s.get("pct"),
"balance": s.get("balance"), "equity": s.get("equity"),
"rsi_m15": round(s["rsi_m15"], 1) if s.get("rsi_m15") else None,
"atr_m15": round(s["atr_m15"], 3) if s.get("atr_m15") else None,
"angles": {k: round(v) for k, v in (s.get("angles") or {}).items()},
"reversal": s.get("reversal"),
}
def _tool_performance(self, cur_setup: str = "") -> dict:
out: dict = {}
# cur_setup: aktuelles Wellen-Setup, um seine Historie hervorzuheben
for period in ("today", "week"):
try:
t = self.history.stats_overview(period)
out[period] = {"trades": t["n_trades"], "winrate": round(t["winrate"]),
"pnl": round(t["total_pnl"], 2),
"profit_factor": (round(t["profit_factor"], 2)
if t["profit_factor"] else None)}
except Exception:
pass
try:
last = self.history.last_closed_trades(5)
out["last_trades"] = [
{"dir": r["direction"], "pnl": round(r["pnl"] or 0, 2),
"by": r["closed_by"], "setup": r.get("setup")}
for r in last]
except Exception:
pass
try:
dry = self.history.intended_summary("today")
out["dry_run_today"] = {"n": dry["n"], "winrate": round(dry["winrate"])}
except Exception:
pass
# Setup-Historie: alle (gefiltert) + das aktuell anstehende Setup separat,
# damit das Modell seine Konfidenz an der echten Bilanz kalibrieren kann
try:
ss = self.history.setup_stats("all")
out["by_setup"] = [
{"setup": s["setup"], "n": s["n"], "winrate": round(s["winrate"]),
"avg_pnl": round(s["avg_pnl"], 2)}
for s in ss if s["n"] >= 3][:6]
match = next((s for s in ss if s["setup"] == cur_setup), None)
if match and cur_setup:
out["current_setup_history"] = {
"setup": cur_setup, "n": match["n"],
"winrate": round(match["winrate"]),
"avg_pnl": round(match["avg_pnl"], 2),
"total_pnl": round(match["total_pnl"], 2)}
elif cur_setup:
out["current_setup_history"] = {
"setup": cur_setup, "n": 0, "note": "noch keine Trades"}
except Exception:
pass
return out
def _tool_news(self) -> dict:
try:
ns = self.news.sentiment_snapshot()
return {"score": ns.get("score"), "label": ns.get("label"),
"drivers": ns.get("drivers", [])[:3]}
except Exception:
return {}
def _tool_elliott(self) -> dict:
"""Elliott-Wave-/FVG-Heuristik (plausibel, nicht sicher)."""
if not self.elliott:
return {}
s = self.elliott.snapshot()
if s.get("stale") or s.get("pattern") in (None, "unclear"):
return {"struktur": "keine klare Welle erkennbar"}
return {
"tf": s.get("tf"),
"pattern": s.get("pattern"),
"wave": s.get("wave"),
"valid": s.get("valid"),
"target": s.get("target"),
"target_label": s.get("target_label"),
"exhaustion": s.get("exhaustion"),
"invalidation": s.get("invalidation"),
"fvg": s.get("fvg"),
"note": s.get("note"),
}
def _tool_zones(self) -> dict:
"""ECHTE S/R-Level = geclusterte M5-Pivots (`wave.pb_levels`, dieselbe Quelle
wie Chart/Dashboard/Auto-Close) — NICHT mehr die stale [zones]-Config (2026-
07-15). Nächste ~3 Widerstände über / ~3 Unterstützungen unter dem Kurs."""
ws = self.wave.snapshot() or {}
lv = ws.get("pb_levels") or {}
atr = (ws.get("pb_feats") or {}).get("atr") or 0.2
cur = (self.data.snapshot() or {}).get("bid")
if not cur or not (lv.get("ph") or lv.get("pl")):
return {}
def cluster(vals):
out = []
for v in sorted(vals):
if out and v - out[-1][-1] <= 0.5 * atr:
out[-1].append(v)
else:
out.append([v])
return [round(sum(g) / len(g), 3) for g in out]
ph = cluster(lv.get("ph") or [])
pl = cluster(lv.get("pl") or [])
res = [{"price": p, "dist": round(p - cur, 3)} for p in ph if p > cur][:3]
sup = [{"price": p, "dist": round(cur - p, 3)}
for p in reversed(pl) if p < cur][:3]
return {"current_price": round(cur, 3),
"resistances": res, "supports": sup}
def _gather_context(self) -> dict:
try:
wsig = self.wave.signal()
except Exception:
wsig = {}
return {
"market": self._tool_market(wsig),
"elliott": self._tool_elliott(),
"zones": self._tool_zones(),
"position": self._tool_position(),
"account": self._tool_account(),
"performance": self._tool_performance(wsig.get("setup", "") or ""),
"news": self._tool_news(),
}
# ── LLM-Aufruf (Provider-Dispatch) ───────────────────────────────────────
def _call_gemini(self, prompt: str) -> str:
import requests
model = self._model_cfg or (self.cfg["gemini"].get("model")
or "gemini-2.0-flash")
resp = requests.post(
_GEMINI_URL.format(model=model),
headers={"x-goog-api-key": self._gemini_key(),
"Content-Type": "application/json"},
json={"contents": [{"role": "user",
"parts": [{"text": _SYS + "\n\n" + prompt}]}],
"generationConfig": {"temperature": 0.3, "maxOutputTokens": 700}},
timeout=60)
resp.raise_for_status()
data = resp.json()
return data["candidates"][0]["content"]["parts"][0]["text"]
def _call_openai(self, prompt: str) -> str:
import requests
model = self._model_cfg or "gpt-4o-mini"
resp = requests.post(
_OPENAI_URL,
headers={"Authorization": f"Bearer {self._openai_key()}",
"Content-Type": "application/json"},
json={"model": model,
"messages": [{"role": "system", "content": _SYS},
{"role": "user", "content": prompt}],
"temperature": 0.3, "max_tokens": 700},
timeout=60)
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
def _call_zai(self, prompt: str) -> str:
# z.ai/GLM (OpenAI-kompatibel). Thinking AUS (sonst geht das Budget ins
# „Denken" und content bleibt leer). Web-Suche hier NICHT nötig (analysiert
# nur den Snapshot). CJK-Drift-Schutz via _has_cjk-Retry im Aufrufer.
import requests
zc = self.cfg["zai"] if self.cfg.has_section("zai") else {}
base = (zc.get("base_url") or "https://api.z.ai/api/paas/v4").rstrip("/")
model = self._model_cfg or (zc.get("model") or "glm-4.5-flash")
resp = requests.post(
base + "/chat/completions",
headers={"Authorization": "Bearer " + (zc.get("api_key") or "").strip()},
json={"model": model,
"messages": [{"role": "system", "content": _SYS},
{"role": "user", "content": prompt}],
"thinking": {"type": "disabled"},
"temperature": 0.3, "max_tokens": 900},
timeout=90)
resp.raise_for_status()
return (resp.json()["choices"][0]["message"].get("content") or "").strip()
def _call_kimi(self, prompt: str) -> str:
# Kimi / Moonshot AI (OpenAI-kompatibel, Endpoint .ai). kimi-k2.6 ist ein
# Reasoning-Modell: es schreibt VARIABLE „reasoning_tokens" (real 10001200
# beim echten _SYS) in message.reasoning_content VOR dem eigentlichen
# `content` → max_tokens muss großzügig sein, sonst frisst das Reasoning das
# Budget und content bleibt leer (gemessen: 2000 = teils leer, 4000 = ok).
# temperature MUSS 1 sein (Modell-Vorgabe, andere Werte → 400). CJK-Drift-
# Schutz via _has_cjk-Retry im Aufrufer (wie zai/local — Kimi ist CN-Modell).
import requests
kc = self.cfg["kimi"] if self.cfg.has_section("kimi") else {}
base = (kc.get("base_url") or "https://api.moonshot.ai/v1").rstrip("/")
model = self._model_cfg or (kc.get("model") or "kimi-k2.6")
resp = requests.post(
base + "/chat/completions",
headers={"Authorization": "Bearer " + (kc.get("api_key") or "").strip(),
"Content-Type": "application/json"},
json={"model": model,
"messages": [{"role": "system", "content": _SYS},
{"role": "user", "content": prompt}],
"temperature": 1, "max_tokens": 4000},
timeout=120)
resp.raise_for_status()
return (resp.json()["choices"][0]["message"].get("content") or "").strip()
def _call_deepseek(self, prompt: str) -> str:
# DeepSeek (OpenAI-kompatibel, api.deepseek.com). deepseek-v4-flash ist ein
# Reasoning-Modell (content nach reasoning_content) → max_tokens großzügig
# (4000), sonst content leer. temperature 0.3 ok. CJK-Drift-Schutz im Aufrufer.
import requests
dc = self.cfg["deepseek"] if self.cfg.has_section("deepseek") else {}
base = (dc.get("base_url") or "https://api.deepseek.com").rstrip("/")
model = self._model_cfg or (dc.get("model") or "deepseek-v4-flash")
resp = requests.post(
base + "/chat/completions",
headers={"Authorization": "Bearer " + (dc.get("api_key") or "").strip(),
"Content-Type": "application/json"},
json={"model": model,
"messages": [{"role": "system", "content": _SYS},
{"role": "user", "content": prompt}],
"temperature": 0.3, "max_tokens": 4000},
timeout=120)
resp.raise_for_status()
return (resp.json()["choices"][0]["message"].get("content") or "").strip()
def _call_ollama(self, prompt: str) -> str:
# Lokales LLM via Ollama (/api/chat). `format`=JSON-Schema erzwingt
# strukturierte Ausgabe. `keep_alive` hält das Modell zwischen den
# 5-Min-Ticks resident auf der GPU — sonst entlädt Ollama nach 5 min
# und lädt neu (Risiko: CPU-Rückfall bei knappem VRAM). Großzügiger
# Timeout, da CPU-Inferenz langsam ist.
import requests
oc = self.cfg["ollama"]
base = (oc.get("base_url") or "http://localhost:11434").rstrip("/")
model = self._model_cfg or (oc.get("model") or "qwen2.5:7b")
keep_alive = oc.get("keep_alive") or "30m"
resp = requests.post(
f"{base}/api/chat",
json={"model": model, "stream": False, "format": _SCHEMA,
"keep_alive": keep_alive,
"options": {"temperature": 0.3},
"messages": [{"role": "system", "content": _SYS},
{"role": "user", "content": prompt}]},
timeout=180)
resp.raise_for_status()
return resp.json()["message"]["content"]
def _call_claude(self, prompt: str) -> str:
# Offizielles anthropic-SDK. Adaptives Thinking (für die Abwägung der
# Signale) + erzwungenes JSON via output_config.format; effort=low, da
# es eine kurze Routine-Beurteilung alle paar Minuten ist.
import anthropic
model = self._model_cfg or (self.cfg["anthropic"].get("model")
or "claude-opus-4-8")
client = anthropic.Anthropic(api_key=self._anthropic_key())
resp = client.messages.create(
model=model,
max_tokens=4096,
system=_SYS,
thinking={"type": "adaptive"},
output_config={"effort": "low",
"format": {"type": "json_schema", "schema": _SCHEMA}},
messages=[{"role": "user", "content": prompt}],
)
if resp.stop_reason == "refusal":
raise RuntimeError("Claude-Refusal (Sicherheits-Klassifikator)")
# output_config.format garantiert: erster text-Block ist valides JSON
return next((b.text for b in resp.content if b.type == "text"), "")
@staticmethod
def _parse(text: str) -> dict:
"""Robustes JSON-Parsing (Code-Fences/Prosa drumherum tolerieren)."""
t = text.strip()
if "{" in t and "}" in t:
t = t[t.index("{"): t.rindex("}") + 1]
data = json.loads(t)
bias = str(data.get("bias", "NEUTRAL")).upper()
if bias not in ("LONG", "SHORT", "NEUTRAL"):
bias = "NEUTRAL"
tf = str(data.get("timeframe", "M5")).upper().strip()
if tf not in ("M1", "M5", "M15", "M30", "H1"):
tf = "M5"
return {
"bias": bias,
"confidence": int(data.get("confidence", 0) or 0),
"timeframe": tf,
"headline": str(data.get("headline", "")).strip(),
"reasoning": str(data.get("reasoning", "")).strip(),
"risks": [str(r) for r in (data.get("risks") or [])][:4],
"position_note": str(data.get("position_note", "")).strip(),
}
# ── Hauptlauf (blockierend — im Hintergrund-Thread aufrufen) ─────────────
def analyze(self) -> bool:
prov = self._active_provider()
if prov is None:
with self._lock:
self._error = ("Kein Provider (config.ini [ollama] model / "
"[anthropic] / [gemini] / [openai])")
return False
with self._lock:
if self._busy:
return False
self._busy = True
try:
ctx = self._gather_context()
prompt = ("Aktueller Systemzustand (JSON):\n"
+ json.dumps(ctx, ensure_ascii=False, indent=1)
+ "\n\nGib deine Beurteilung als JSON zurück. "
"Alle Texte auf DEUTSCH, nur lateinische Schrift "
"(keine chinesischen Zeichen).")
raw = (self._call_ollama(prompt) if prov == "local"
else self._call_zai(prompt) if prov == "zai"
else self._call_kimi(prompt) if prov == "kimi"
else self._call_deepseek(prompt) if prov == "deepseek"
else self._call_claude(prompt) if prov == "claude"
else self._call_openai(prompt) if prov == "openai"
else self._call_gemini(prompt))
parsed = self._parse(raw)
# CN-/lokale Modelle (Qwen, z.ai, Kimi, DeepSeek) driften gelegentlich ins
# Chinesische → einmal mit verschärfter Anweisung neu versuchen.
if prov in ("local", "zai", "kimi", "deepseek") and _has_cjk(parsed):
log.warning(f"[{prov}] CJK-Zeichen erkannt — Wiederholung auf Deutsch")
retry = (self._call_zai if prov == "zai"
else self._call_kimi if prov == "kimi"
else self._call_deepseek if prov == "deepseek"
else self._call_ollama)
raw = retry(prompt + "\n\nACHTUNG: Schreibe AUSSCHLIESSLICH auf "
"DEUTSCH, NUR lateinische Buchstaben, KEINE chinesischen "
"Zeichen.")
p2 = self._parse(raw)
if not _has_cjk(p2):
parsed = p2
with self._lock:
self._last = parsed
self._error = None
self._ts = time.time()
log.info(f"[{prov}] {parsed['bias']} ({parsed['confidence']}%) — "
f"{parsed['headline'][:80]}")
return True
except Exception as e:
with self._lock:
self._error = str(e)[:140]
log.warning(f"Agent-Analyse fehlgeschlagen ({prov}): {e}")
return False
finally:
with self._lock:
self._busy = False
def snapshot(self) -> dict:
with self._lock:
return {
"advisory": dict(self._last) if self._last else None,
"error": self._error,
"last_update": self._ts,
"busy": self._busy,
"provider": self._active_provider(),
"configured": self.is_configured(),
}
+343
View File
@@ -0,0 +1,343 @@
"""
core/ai.py — KI-Marktanalyse via OpenAI ChatGPT
=================================================
Nutzt `gpt-4o-mini-search-preview` (oder das größere `gpt-4o-search-preview`)
für eine Crude-Oil-Marktanalyse (WTI + Brent) mit Web-Suche.
Parsed eine strukturierte Antwort:
SENTIMENT: bullish|bearish|neutral
CONFIDENCE: 0-100
SUMMARY: <2-3 Sätze>
DRIVERS: <Treiber1> | <Treiber2> | <Treiber3>
Loggt jede Analyse in die History-DB (falls injiziert).
"""
from __future__ import annotations
import threading
import time
from core.logger import get_logger
log_ai = get_logger("ai")
log_hist = get_logger("hist")
class OpenAIAnalyzer:
"""
Ruft die OpenAI Chat-Completions API auf.
Web-Search ist bei *-search-preview-Modellen automatisch aktiv,
konfigurierbar über `search_context` (low|medium|high).
"""
PROMPT = (
"Du bist ein professioneller Rohstoff-Marktanalyst. Suche im Web nach den "
"wichtigsten Crude-Oil-News der letzten 24 Stunden — sowohl für "
"WTI (US Crude) als auch Brent (OPEC, US-Lagerbestände/EIA, "
"Geopolitik Naher Osten, Förderdaten, Nachfrage-Indikatoren, "
"Pipelines, Raffinerien).\n\n"
"Antworte AUSSCHLIESSLICH in genau diesem Format (deutsche Sprache):\n"
"SENTIMENT: bullish|bearish|neutral\n"
"CONFIDENCE: <0-100>\n"
"SUMMARY: <2-3 Sätze, prägnant, nur preisbewegende Faktoren>\n"
"DRIVERS: <Treiber1> | <Treiber2> | <Treiber3>\n\n"
"Sei knapp und konkret. Keine Disclaimer, keine Einleitung."
)
# Preis pro Mio Token (USD) grobe Schätzung für Cost-Anzeige
PRICES = {
"gpt-4o-mini-search-preview": (0.15, 0.60, 25.0),
"gpt-4o-search-preview": (2.50, 10.00, 30.0),
"_default": (0.15, 0.60, 25.0),
}
def __init__(self, api_key: str, model: str, search_context: str = "low"):
self.api_key = api_key.strip()
self.model = model.strip() or "gpt-4o-mini-search-preview"
self.search_context = search_context.strip().lower() or "low"
self.sentiment = None
self.confidence = None
self.summary = ""
self.drivers = []
self.last_update = None
self.last_cost = None
self.error = None
self.busy = False
self._lock = threading.Lock()
# History-Logger wird vom main() injiziert
self.history = None
def is_configured(self) -> bool:
return bool(self.api_key) and self.api_key.startswith(("sk-", "sk-proj-"))
def _estimate_cost(self, in_tok: int, out_tok: int) -> float:
in_p, out_p, search_p = self.PRICES.get(self.model, self.PRICES["_default"])
token_cost = (in_tok * in_p + out_tok * out_p) / 1_000_000
search_cost = search_p / 1000.0
return token_cost + search_cost
def analyze(self):
if not self.is_configured():
with self._lock:
self.error = "Kein API-Key (config.ini)"
return
try:
from openai import OpenAI
except ImportError:
with self._lock:
self.error = "pip install openai"
return
with self._lock:
if self.busy:
return
self.busy = True
self.error = None
try:
client = OpenAI(api_key=self.api_key)
kwargs = {
"model": self.model,
"messages": [{"role": "user", "content": self.PROMPT}],
}
if "search-preview" in self.model:
kwargs["web_search_options"] = {
"search_context_size": self.search_context,
}
else:
kwargs["max_tokens"] = 700
res = client.chat.completions.create(**kwargs)
text = (res.choices[0].message.content or "").strip()
# Strukturierte Antwort parsen
sentiment = "neutral"
confidence = 50
summary = text[:300]
drivers = []
for raw in text.split("\n"):
line = raw.strip()
low = line.lower()
if low.startswith("sentiment:"):
v = low.split(":", 1)[1].strip()
sentiment = ("bullish" if "bull" in v
else "bearish" if "bear" in v else "neutral")
elif low.startswith("confidence:"):
digits = "".join(c for c in line.split(":", 1)[1] if c.isdigit())[:3]
if digits:
confidence = max(0, min(100, int(digits)))
elif low.startswith("summary:"):
summary = line.split(":", 1)[1].strip()
elif low.startswith("drivers:"):
parts = line.split(":", 1)[1]
drivers = [d.strip() for d in parts.split("|") if d.strip()][:4]
# Token-Verbrauch & Kosten
usage = getattr(res, "usage", None)
cost_est = None
if usage:
in_tok = getattr(usage, "prompt_tokens", 0) or 0
out_tok = getattr(usage, "completion_tokens", 0) or 0
cost_est = self._estimate_cost(in_tok, out_tok)
with self._lock:
self.sentiment = sentiment
self.confidence = confidence
self.summary = summary
self.drivers = drivers
self.last_update = time.time()
self.last_cost = cost_est
self.error = None
log_ai.info(f"{sentiment.upper()} ({confidence}%) — {summary[:80]}")
if cost_est:
log_ai.info(f"Geschätzte Kosten: ${cost_est:.4f}")
if self.history:
try:
self.history.log_ai(
sentiment = sentiment,
confidence = confidence,
summary = summary,
drivers = drivers,
cost_estimate= cost_est,
model = self.model,
)
except Exception as e:
log_hist.error(f"log_ai: {e}")
except Exception as e:
err = str(e)
with self._lock:
self.error = err[:120]
log_ai.error(f"Fehler: {err}")
finally:
with self._lock:
self.busy = False
def snapshot(self):
with self._lock:
return {
"sentiment": self.sentiment,
"confidence": self.confidence,
"summary": self.summary,
"drivers": list(self.drivers),
"last_update": self.last_update,
"last_cost": self.last_cost,
"error": self.error,
"busy": self.busy,
"configured": self.is_configured(),
"model": self.model,
}
class GeminiAnalyzer:
"""
Ruft die Google Gemini API auf (REST, via requests).
Nutzt Google Search Grounding für aktuelle Web-Daten.
"""
PROMPT = (
"Du bist ein professioneller Rohstoff-Marktanalyst. Suche im Web nach den "
"wichtigsten Crude-Oil-News der letzten 24 Stunden — sowohl für "
"WTI (US Crude) als auch Brent (OPEC, US-Lagerbestände/EIA, "
"Geopolitik Naher Osten, Förderdaten, Nachfrage-Indikatoren, "
"Pipelines, Raffinerien).\n\n"
"Antworte AUSSCHLIESSLICH in genau diesem Format (deutsche Sprache):\n"
"SENTIMENT: bullish|bearish|neutral\n"
"CONFIDENCE: <0-100>\n"
"SUMMARY: <2-3 Sätze, prägnant, nur preisbewegende Faktoren>\n"
"DRIVERS: <Treiber1> | <Treiber2> | <Treiber3>\n\n"
"Sei knapp und konkret. Keine Disclaimer, keine Einleitung."
)
_API_BASE = "https://generativelanguage.googleapis.com/v1beta/models"
def __init__(self, api_key: str, model: str = "gemini-2.0-flash"):
self.api_key = api_key.strip()
self.model = model.strip() or "gemini-2.0-flash"
self.sentiment = None
self.confidence = None
self.summary = ""
self.drivers = []
self.last_update = None
self.error = None
self.busy = False
self._lock = threading.Lock()
self.history = None
def is_configured(self) -> bool:
return bool(self.api_key) and self.api_key.startswith("AIza")
def analyze(self):
if not self.is_configured():
with self._lock:
self.error = "Kein Gemini API-Key (config.ini)"
return
try:
import requests as _req
except ImportError:
with self._lock:
self.error = "pip install requests"
return
with self._lock:
if self.busy:
return
self.busy = True
self.error = None
try:
url = f"{self._API_BASE}/{self.model}:generateContent"
payload = {
"contents": [{"role": "user", "parts": [{"text": self.PROMPT}]}],
"tools": [{"google_search": {}}],
"generationConfig": {
"temperature": 0.3,
"maxOutputTokens": 600,
},
}
# Key im Header statt als URL-Parameter — sonst landet er bei
# jedem HTTP-Fehler im Klartext in der Log-Fehlermeldung (URL)
resp = _req.post(
url,
headers={"x-goog-api-key": self.api_key},
json=payload,
timeout=60,
)
resp.raise_for_status()
result = resp.json()
text = ""
try:
text = result["candidates"][0]["content"]["parts"][0]["text"].strip()
except (KeyError, IndexError):
text = str(result)[:300]
sentiment = "neutral"
confidence = 50
summary = text[:300]
drivers = []
for raw in text.split("\n"):
line = raw.strip()
low = line.lower()
if low.startswith("sentiment:"):
v = low.split(":", 1)[1].strip()
sentiment = ("bullish" if "bull" in v
else "bearish" if "bear" in v else "neutral")
elif low.startswith("confidence:"):
digits = "".join(c for c in line.split(":", 1)[1] if c.isdigit())[:3]
if digits:
confidence = max(0, min(100, int(digits)))
elif low.startswith("summary:"):
summary = line.split(":", 1)[1].strip()
elif low.startswith("drivers:"):
parts = line.split(":", 1)[1]
drivers = [d.strip() for d in parts.split("|") if d.strip()][:4]
with self._lock:
self.sentiment = sentiment
self.confidence = confidence
self.summary = summary
self.drivers = drivers
self.last_update = time.time()
self.error = None
log_ai.info(f"[Gemini] {sentiment.upper()} ({confidence}%) — {summary[:80]}")
if self.history:
try:
self.history.log_ai(
sentiment = sentiment,
confidence = confidence,
summary = summary,
drivers = drivers,
cost_estimate= None,
model = self.model,
)
except Exception as e:
log_hist.error(f"[Gemini] log_ai: {e}")
except Exception as e:
err = str(e)
with self._lock:
self.error = err[:120]
log_ai.error(f"[Gemini] Fehler: {err}")
finally:
with self._lock:
self.busy = False
def snapshot(self):
with self._lock:
return {
"sentiment": self.sentiment,
"confidence": self.confidence,
"summary": self.summary,
"drivers": list(self.drivers),
"last_update": self.last_update,
"last_cost": None,
"error": self.error,
"busy": self.busy,
"configured": self.is_configured(),
"model": self.model,
}
+51
View File
@@ -0,0 +1,51 @@
"""
core/analysis/__init__.py
Re-exportiert alle öffentlichen Symbole für Rückwärtskompatibilität.
Alle bestehenden Imports wie `from core.analysis import calc_recommendation`
funktionieren unverändert weiter.
"""
from core.analysis.indicators import (
_ema,
calc_trend_angle,
FIB_RATIOS,
calc_rsi,
calc_atr,
calc_volume_ratio,
calc_fib_levels,
calc_fib_distance,
detect_regime,
calc_sr_distance,
calc_sma,
calc_vwap,
)
from core.analysis.ict import (
calc_bos,
calc_fvg,
calc_asia_levels,
calc_liquidity_sweep,
calc_order_block,
calc_ichimoku,
calc_coc,
)
from core.analysis.news import (
NEWS_BULLISH_KW,
NEWS_BEARISH_KW,
calc_news_sentiment,
)
from core.analysis.m15 import M15Analyzer, SRDetector
__all__ = [
"_ema", "calc_trend_angle", "FIB_RATIOS",
"calc_rsi", "calc_atr", "calc_volume_ratio",
"calc_fib_levels", "calc_fib_distance",
"detect_regime", "calc_sr_distance",
"calc_sma", "calc_vwap",
"calc_bos", "calc_fvg", "calc_asia_levels",
"calc_liquidity_sweep", "calc_order_block", "calc_ichimoku", "calc_coc",
"NEWS_BULLISH_KW", "NEWS_BEARISH_KW", "calc_news_sentiment",
"M15Analyzer", "SRDetector",
]
+348
View File
@@ -0,0 +1,348 @@
"""
core/analysis/ict.py — ICT / SMC Konzepte
BOS, FVG, Asia Levels, Liquidity Sweep, Order Block, Ichimoku
"""
from __future__ import annotations
def calc_bos(highs: list, lows: list, closes: list,
lookback: int = 30, pivot_win: int = 3) -> dict:
"""
Break of Structure (ICT/SMC).
Rückgabe: {'bos': 'bullish'|'bearish'|None, 'bos_level': float|None, 'bars_ago': int|None}
"""
n = len(closes)
if n < lookback + pivot_win + 3:
return {"bos": None, "bos_level": None, "bars_ago": None}
w = pivot_win
search_end = n - 1
last_swing_high = last_swing_low = None
for i in range(search_end - w - 1, max(w, search_end - lookback - 1), -1):
lo = max(0, i - w); hi_r = min(n - 1, i + w)
if last_swing_high is None and highs[i] == max(highs[lo : hi_r + 1]):
last_swing_high = highs[i]
if last_swing_low is None and lows[i] == min(lows[lo : hi_r + 1]):
last_swing_low = lows[i]
if last_swing_high is not None and last_swing_low is not None:
break
if last_swing_high is None or last_swing_low is None:
return {"bos": None, "bos_level": None, "bars_ago": None}
for ago in range(1, 6):
if n - ago - 1 < 1:
break
c_now = closes[n - ago]
c_prev = closes[n - ago - 1]
if c_now < last_swing_low <= c_prev:
return {"bos": "bearish", "bos_level": last_swing_low, "bars_ago": ago}
if c_now > last_swing_high >= c_prev:
return {"bos": "bullish", "bos_level": last_swing_high, "bars_ago": ago}
return {"bos": None, "bos_level": None, "bars_ago": None}
def calc_fvg(highs: list, lows: list, closes: list, lookback: int = 20) -> dict:
"""
Fair Value Gap / Imbalance (ICT-Definition).
Rückgabe: {'type': 'bullish'|'bearish'|None, 'top', 'bottom', 'mid', 'filled_pct', 'bars_ago'}
"""
n = len(closes)
if n < 4:
return {"type": None}
cur = closes[-1]
for i in range(n - 3, max(1, n - lookback - 2), -1):
if i + 2 >= n:
continue
h_before = highs[i - 1]; l_before = lows[i - 1]
h_after = highs[i + 1]; l_after = lows[i + 1]
if h_before < l_after:
bottom, top = h_before, l_after
if top <= bottom:
continue
filled_pct = max(0.0, min(100.0, (cur - bottom) / (top - bottom) * 100))
if filled_pct < 100.0:
return {"type": "bullish", "top": top, "bottom": bottom,
"mid": (top + bottom) / 2,
"filled_pct": round(filled_pct, 0), "bars_ago": n - 2 - i}
elif l_before > h_after:
bottom, top = h_after, l_before
if top <= bottom:
continue
filled_pct = max(0.0, min(100.0, (top - cur) / (top - bottom) * 100))
if filled_pct < 100.0:
return {"type": "bearish", "top": top, "bottom": bottom,
"mid": (top + bottom) / 2,
"filled_pct": round(filled_pct, 0), "bars_ago": n - 2 - i}
return {"type": None}
def calc_asia_levels(bars: list) -> dict | None:
"""
Asien-Session Hoch/Tief (00:0008:00 UTC) aus M15-Bars.
Rückgabe: {'high': float, 'low': float, 'n': int} oder None.
"""
if not bars:
return None
import time as _time
from datetime import datetime, timezone as _tz
now_ts = _time.time()
today_utc = datetime.fromtimestamp(now_ts, tz=_tz.utc).replace(
hour=0, minute=0, second=0, microsecond=0)
today_ts = today_utc.timestamp()
asia_end_ts = today_ts + 8 * 3600
asia_bars = [b for b in bars
if today_ts <= int(b["time"]) < asia_end_ts]
if not asia_bars:
return None
return {
"high": max(float(b["high"]) for b in asia_bars),
"low": min(float(b["low"]) for b in asia_bars),
"n": len(asia_bars),
}
def calc_liquidity_sweep(highs: list, lows: list, closes: list, opens: list,
lookback: int = 25, pivot_win: int = 3) -> dict:
"""
Liquidity Sweep (ICT): Wick über Swing-High/-Low, Schluss zurück.
Rückgabe: {'sweep': 'bearish'|'bullish'|None, 'level': float|None, 'bars_ago': int|None}
"""
n = len(closes)
if n < lookback + pivot_win + 3:
return {"sweep": None, "level": None, "bars_ago": None}
w = pivot_win
search_end = n - 1
swing_highs = []
swing_lows = []
for i in range(max(w, search_end - lookback), search_end - w):
lo = max(0, i - w); hi_r = min(n - 1, i + w)
if highs[i] == max(highs[lo : hi_r + 1]):
swing_highs.append(highs[i])
if lows[i] == min(lows[lo : hi_r + 1]):
swing_lows.append(lows[i])
if not swing_highs or not swing_lows:
return {"sweep": None, "level": None, "bars_ago": None}
pivot_high = max(swing_highs)
pivot_low = min(swing_lows)
for ago in range(1, 4):
idx = n - ago - 1
if idx < 1:
break
h = highs[idx]; l = lows[idx]; c = closes[idx]
if h > pivot_high and c < pivot_high:
return {"sweep": "bearish", "level": pivot_high, "bars_ago": ago}
if l < pivot_low and c > pivot_low:
return {"sweep": "bullish", "level": pivot_low, "bars_ago": ago}
return {"sweep": None, "level": None, "bars_ago": None}
def calc_order_block(highs: list, lows: list, closes: list, opens: list,
lookback: int = 40, min_impulse_bars: int = 3,
atr: float | None = None) -> dict:
"""
Order Block (ICT/SMC).
Bullish OB: letzter Bear-Candle vor starkem Aufwärts-Impuls → Support-Zone
Bearish OB: letzter Bull-Candle vor starkem Abwärts-Impuls → Resistance-Zone
Rückgabe: {'type': 'bullish'|'bearish'|None, 'high', 'low', 'mid', 'bars_ago', 'mitigated'}
"""
n = len(closes)
if n < lookback + min_impulse_bars + 2:
return {"type": None}
atr_eff = atr if atr and atr > 0 else 0.5
min_move = 1.5 * atr_eff
for end in range(n - min_impulse_bars - 1, max(1, n - lookback - 1), -1):
if end + min_impulse_bars >= n:
continue
bull_move = closes[end + min_impulse_bars] - closes[end]
bear_move = closes[end] - closes[end + min_impulse_bars]
if bull_move > min_move:
for ob_i in range(end, max(0, end - 6), -1):
if closes[ob_i] < opens[ob_i]:
ob_h = highs[ob_i]; ob_l = lows[ob_i]
mit = any(lows[j] < ob_h and highs[j] > ob_l
for j in range(ob_i + 1, n))
return {"type": "bullish", "high": ob_h, "low": ob_l,
"mid": (ob_h + ob_l) / 2,
"bars_ago": n - 1 - ob_i, "mitigated": mit}
elif bear_move > min_move:
for ob_i in range(end, max(0, end - 6), -1):
if closes[ob_i] > opens[ob_i]:
ob_h = highs[ob_i]; ob_l = lows[ob_i]
mit = any(highs[j] > ob_l and lows[j] < ob_h
for j in range(ob_i + 1, n))
return {"type": "bearish", "high": ob_h, "low": ob_l,
"mid": (ob_h + ob_l) / 2,
"bars_ago": n - 1 - ob_i, "mitigated": mit}
return {"type": None}
def calc_coc(highs: list, lows: list, closes: list,
lookback: int = 50, pivot_win: int = 3) -> dict:
"""
Change of Character (CoC / CHOCH) — ICT/SMC Trendumkehrsignal.
Algorithmus:
1. Finde das jüngste Swing-High UND das jüngste Swing-Low im Lookback.
2. Welches Extrem ist jünger bestimmt den vorherigen Bias:
• SH jünger → Uptrend → suche das letzte Swing-Low VOR dem SH (= Higher Low)
Wenn Close unter dieses HL bricht → bearischer CoC
• SL jünger → Downtrend → suche das letzte Swing-High VOR dem SL (= Lower High)
Wenn Close über dieses LH bricht → bullischer CoC
Unterschied zu BOS:
BOS = Strukturbruch IN Trendrichtung (Fortsetzung)
CoC = Strukturbruch GEGEN den Trend (Umkehrsignal, stärker)
Rückgabe:
coc: 'bearish' | 'bullish' | None
coc_level: gebrochenes Strukturniveau (Higher Low / Lower High)
swing_extreme: letztes Swing-Extrem (SH/SL = der Pivot der den Trend definierte)
bars_ago: Bars seit dem Bruch
"""
n = len(closes)
if n < pivot_win * 2 + 12:
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
w = pivot_win
lb = min(lookback, n - w - 2)
def _find_pivot(seq_high: bool, start: int, stop: int) -> tuple[int, float] | None:
for i in range(start, max(w, stop), -1):
lo = max(0, i - w); hi_r = min(n - 1, i + w)
if seq_high and highs[i] == max(highs[lo:hi_r + 1]):
return (i, highs[i])
if not seq_high and lows[i] == min(lows[lo:hi_r + 1]):
return (i, lows[i])
return None
# ── Jüngstes Swing-High und Swing-Low im Lookback ────────────────────────
recent_sh = _find_pivot(True, n - 1 - w, n - lb - 1)
recent_sl = _find_pivot(False, n - 1 - w, n - lb - 1)
if recent_sh is None or recent_sl is None:
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
sh_idx, sh_price = recent_sh
sl_idx, sl_price = recent_sl
lb_stop = max(w, n - lb - 1) # ältestes Bar das in Lookback fällt
# ── Bearish CoC: letztes Extrem war ein Swing-High ────────────────────────
if sh_idx > sl_idx:
# Suche den Swing-Low VOR dem SH (= der Higher Low im Uptrend)
# Suchbereich: komplett rückwärts bis Ende des Lookback-Fensters
hl = _find_pivot(False, sh_idx - w - 1, lb_stop)
if hl is None:
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
hl_price = hl[1]
for ago in range(1, 10):
if n - ago - 1 < 1:
break
c_now = closes[n - ago]
c_prev = closes[n - ago - 1]
if c_now < hl_price <= c_prev:
return {"coc": "bearish", "coc_level": round(hl_price, 5),
"swing_extreme": round(sh_price, 5), "bars_ago": ago}
# ── Bullish CoC: letztes Extrem war ein Swing-Low ─────────────────────────
elif sl_idx > sh_idx:
# Suche den Swing-High VOR dem SL (= der Lower High im Downtrend)
lh = _find_pivot(True, sl_idx - w - 1, lb_stop)
if lh is None:
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
lh_price = lh[1]
for ago in range(1, 10):
if n - ago - 1 < 1:
break
c_now = closes[n - ago]
c_prev = closes[n - ago - 1]
if c_now > lh_price >= c_prev:
return {"coc": "bullish", "coc_level": round(lh_price, 5),
"swing_extreme": round(sl_price, 5), "bars_ago": ago}
return {"coc": None, "coc_level": None, "swing_extreme": None, "bars_ago": None}
def calc_ichimoku(highs: list, lows: list, closes: list,
tenkan: int = 9, kijun: int = 26, senkou_b: int = 52) -> dict | None:
"""
Ichimoku Kinko Hyo — Wolken-Analyse (Standard 9/26/52).
ichi_bias: 4=strong_bull, 3=bull, 2=neutral, 1=bear, 0=strong_bear
"""
n = len(closes)
if n < senkou_b + kijun + 1:
return None
def midpoint(h_sl, l_sl):
return (max(h_sl) + min(l_sl)) / 2
tenkan_val = midpoint(highs[-tenkan:], lows[-tenkan:])
kijun_val = midpoint(highs[-kijun:], lows[-kijun:])
off = kijun
if n - off - 1 < senkou_b:
return None
idx = n - off - 1
t_ago = midpoint(highs[idx - tenkan + 1: idx + 1], lows[idx - tenkan + 1: idx + 1])
k_ago = midpoint(highs[idx - kijun + 1: idx + 1], lows[idx - kijun + 1: idx + 1])
a_val = (t_ago + k_ago) / 2
b_val = midpoint(highs[idx - senkou_b + 1: idx + 1], lows[idx - senkou_b + 1: idx + 1])
cloud_top = max(a_val, b_val)
cloud_bot = min(a_val, b_val)
cur = closes[-1]
price_vs_cloud = ("above" if cur > cloud_top else
"below" if cur < cloud_bot else "inside")
tk_signal = "bullish" if tenkan_val >= kijun_val else "bearish"
cloud_color = "green" if a_val >= b_val else "red"
chikou_signal = "neutral"
if n > kijun:
ref = closes[n - 1 - kijun]
chikou_signal = "bullish" if cur > ref else ("bearish" if cur < ref else "neutral")
bull_pts = (
(1 if price_vs_cloud == "above" else 0) +
(1 if tk_signal == "bullish" else 0) +
(1 if chikou_signal == "bullish" else 0) +
(1 if cloud_color == "green" else 0)
)
ichi_bias = {4: "strong_bull", 3: "bull", 1: "bear", 0: "strong_bear"}.get(bull_pts, "neutral")
return {
"tenkan": round(tenkan_val, 3),
"kijun": round(kijun_val, 3),
"senkou_a": round(a_val, 3),
"senkou_b": round(b_val, 3),
"cloud_top": round(cloud_top, 3),
"cloud_bot": round(cloud_bot, 3),
"cloud_color": cloud_color,
"price_vs_cloud": price_vs_cloud,
"tk_signal": tk_signal,
"chikou_signal": chikou_signal,
"ichi_bias": ichi_bias,
"bull_pts": bull_pts,
}
+252
View File
@@ -0,0 +1,252 @@
"""
core/analysis/indicators.py — Mathematische Indikatoren & Helper
"""
from __future__ import annotations
import math
from core.config import ANGLE_LR_BARS
def _ema(values: list, period: int) -> list:
"""Exponentieller gleitender Durchschnitt."""
k = 2.0 / (period + 1)
out = [values[0]]
for v in values[1:]:
out.append(v * k + out[-1] * (1 - k))
return out
def calc_trend_angle(closes: list, n: int = ANGLE_LR_BARS) -> float:
"""
Lineare Regression über die letzten n Schlusskurse.
Liefert 0°–180°: 0° = starker Aufwärtstrend
90° = seitwärts
180° = starker Abwärtstrend
"""
data = closes[-n:] if len(closes) >= n else closes
k = len(data)
if k < 2:
return 90.0
xm = (k - 1) / 2.0
ym = sum(data) / k
num = sum((i - xm) * (v - ym) for i, v in enumerate(data))
den = sum((i - xm) ** 2 for i in range(k))
if den == 0:
return 90.0
slope = num / den
slope_pct = slope / (ym or 1) * 100
angle_rad = math.atan(slope_pct * 18)
angle = 90.0 - math.degrees(angle_rad)
return max(0.0, min(180.0, angle))
FIB_RATIOS = (0.236, 0.382, 0.500, 0.618, 0.786)
def calc_rsi(closes: list, period: int = 14) -> float:
"""Wilder-RSI über die letzten `period` Schlusskurse. 0100."""
if len(closes) < period + 1:
return 50.0
gains, losses = 0.0, 0.0
for i in range(1, period + 1):
diff = closes[i] - closes[i - 1]
if diff >= 0:
gains += diff
else:
losses -= diff
avg_g = gains / period
avg_l = losses / period
for i in range(period + 1, len(closes)):
diff = closes[i] - closes[i - 1]
g = max(diff, 0.0)
l = max(-diff, 0.0)
avg_g = (avg_g * (period - 1) + g) / period
avg_l = (avg_l * (period - 1) + l) / period
if avg_l == 0:
return 100.0
rs = avg_g / avg_l
return 100.0 - 100.0 / (1.0 + rs)
def calc_atr(highs: list, lows: list, closes: list, period: int = 14) -> float | None:
"""Average True Range, Wilder-Smoothing. Liefert None bei zu wenig Daten."""
n = len(highs)
if n < period + 1 or n != len(lows) or n != len(closes):
return None
trs = []
for i in range(1, n):
tr = max(highs[i] - lows[i],
abs(highs[i] - closes[i - 1]),
abs(lows[i] - closes[i - 1]))
trs.append(tr)
if len(trs) < period:
return None
atr = sum(trs[:period]) / period
for tr in trs[period:]:
atr = (atr * (period - 1) + tr) / period
return atr
def calc_volume_ratio(bars: list, lookback: int = 20) -> float:
"""
Verhältnis des letzten Bar-Tick-Volumens zum Ø der vorherigen lookback Bars.
> 1.5 = überdurchschnittlich, < 0.7 = unterdurchschnittlich
"""
vols = []
for b in bars:
try:
vols.append(float(b["tick_volume"] or 0))
except Exception:
vols.append(0.0)
if len(vols) < 3:
return 1.0
window = vols[-(lookback + 1):-1]
avg = sum(window) / len(window) if window else 0.0
return round(vols[-1] / avg, 2) if avg > 0 else 1.0
def calc_fib_levels(highs: list, lows: list, lookback: int = 50) -> dict | None:
"""
Fibonacci-Retracement-Level aus dem größten Swing-High/Low im lookback-Fenster.
"""
n = len(highs)
if n < 10 or n != len(lows):
return None
start = max(0, n - lookback)
win_h = highs[start:]
win_l = lows[start:]
hi_rel = max(range(len(win_h)), key=lambda i: win_h[i])
lo_rel = min(range(len(win_l)), key=lambda i: win_l[i])
swing_high = win_h[hi_rel]
swing_low = win_l[lo_rel]
rng = swing_high - swing_low
if rng <= 0:
return None
up_levels = {round(r * 100, 1): round(swing_high - r * rng, 5) for r in FIB_RATIOS}
down_levels = {round(r * 100, 1): round(swing_low + r * rng, 5) for r in FIB_RATIOS}
hi_idx = start + hi_rel
lo_idx = start + lo_rel
return {
"swing_high": swing_high,
"swing_low": swing_low,
"high_idx": hi_idx,
"low_idx": lo_idx,
"range": rng,
"direction": "up" if hi_idx > lo_idx else "down",
"levels": {"up": up_levels, "down": down_levels},
}
def calc_fib_distance(price: float, fib: dict | None, direction: str = "up") -> dict:
"""Abstand des Preises zum nächsten Schlüssel-Fib-Level (38.2, 50.0, 61.8)."""
empty = {"nearest_ratio": None, "nearest_price": None, "dist_pct": None}
if not fib or not price:
return empty
levels = fib["levels"].get(direction, {})
key = {k: v for k, v in levels.items() if k in (38.2, 50.0, 61.8)}
if not key:
return empty
nearest_ratio, nearest_price = min(key.items(), key=lambda kv: abs(kv[1] - price))
rng = fib.get("range", 1)
dist_pct = abs(nearest_price - price) / rng * 100 if rng > 0 else None
return {
"nearest_ratio": nearest_ratio,
"nearest_price": nearest_price,
"dist_pct": round(dist_pct, 1) if dist_pct is not None else None,
}
def detect_regime(angles: dict) -> str:
"""
Klassifiziert den Markt-Zustand anhand der 4 TF-Winkel.
Rückgabewerte: 'trend_up', 'trend_down', 'range', 'transition'
"""
if not angles:
return "transition"
vals = [angles.get(tf, 90.0) for tf in ("M5", "M15", "M30", "H1")]
spread = max(vals) - min(vals)
if all(v < 80 for v in vals):
return "trend_up"
if all(v > 100 for v in vals):
return "trend_down"
if spread < 25 and all(75 <= v <= 105 for v in vals):
return "range"
return "transition"
def calc_sma(values: list, period: int = 50) -> float | None:
"""Simple Moving Average über die letzten `period` Werte."""
if len(values) < period:
return None
return sum(values[-period:]) / period
def calc_vwap(bars: list) -> dict | None:
"""
Daily VWAP (Volume Weighted Average Price) aus Intraday-Bars.
Reset täglich um 00:00 UTC.
reclaim=True: Preis war in den letzten 3 Bars unter VWAP,
aktuelle Bar schloss darüber → VWAP-Reclaim-Signal.
Rückgabe: {'vwap', 'price_vs_vwap': 'above'|'below'|'at',
'reclaim': bool, 'n_bars': int, 'diff_pct': float}
"""
import time as _t
from datetime import datetime, timezone as _tz
if not bars:
return None
now_ts = _t.time()
today_utc = datetime.fromtimestamp(now_ts, tz=_tz.utc).replace(
hour=0, minute=0, second=0, microsecond=0)
today_ts = today_utc.timestamp()
today_bars = [b for b in bars if int(b["time"]) >= today_ts]
if len(today_bars) < 2:
return None
cum_tpv = 0.0
cum_vol = 0.0
vwap_series = []
for b in today_bars:
tp = (float(b["high"]) + float(b["low"]) + float(b["close"])) / 3
vol = float(b["tick_volume"] or 1)
cum_tpv += tp * vol
cum_vol += vol
vwap_series.append(cum_tpv / cum_vol if cum_vol > 0 else tp)
vwap = vwap_series[-1]
cur = float(today_bars[-1]["close"])
n = len(today_bars)
# Reclaim: letzte ≥2 Bars unter VWAP, jetzt drüber
prev_below = (n >= 3 and all(
float(today_bars[i]["close"]) < vwap_series[i]
for i in range(max(0, n - 3), n - 1)
))
reclaim = prev_below and (cur > vwap)
diff_pct = (cur - vwap) / vwap * 100 if vwap > 0 else 0.0
return {
"vwap": round(vwap, 3),
"price_vs_vwap": ("above" if cur > vwap * 1.0001 else
"below" if cur < vwap * 0.9999 else "at"),
"reclaim": reclaim,
"n_bars": n,
"diff_pct": round(diff_pct, 2),
}
def calc_sr_distance(price: float, sr: dict | None, atr: float | None) -> dict:
"""Distanz zum nächsten Support/Resistance in ATR-Einheiten."""
if not sr or not atr or atr <= 0 or not price:
return {"support_atr": None, "resistance_atr": None}
sups = sr.get("supports") or []
ress = sr.get("resistances") or []
s_dists = [price - s["price"] for s in sups if s.get("price") and s["price"] < price]
r_dists = [r["price"] - price for r in ress if r.get("price") and r["price"] > price]
return {
"support_atr": (min(s_dists) / atr) if s_dists else None,
"resistance_atr": (min(r_dists) / atr) if r_dists else None,
}
+257
View File
@@ -0,0 +1,257 @@
"""
core/analysis/m15.py — M15Analyzer und SRDetector
"""
from __future__ import annotations
import threading
import time
import MetaTrader5 as mt5
from core.config import (
M15_BARS, EMA_FAST, EMA_SLOW, PIVOT_WINDOW, ANGLE_LR_BARS,
SR_LOOKBACK, SR_PIVOT_WIN, SR_MIN_TOUCHES, SR_TOL_ATR_FACTOR,
SR_MAX_LINES, TL_MIN_PIVOTS, CHART_BARS,
)
from core.analysis.indicators import _ema
class M15Analyzer:
"""
Erkennt M15-Trendwenden anhand von 3 Indikatoren:
1. EMA(5)/EMA(13)-Crossover
2. Bullish/Bearish Engulfing
3. Frische Swing-Highs/Lows
Mindestens 2 Indikatoren müssen in dieselbe Richtung zeigen.
"""
def __init__(self, sym):
self.symbol = sym
self.result = None
self.reasons = []
self._lock = threading.Lock()
def analyze(self):
bars = mt5.copy_rates_from_pos(self.symbol, mt5.TIMEFRAME_M15, 0, M15_BARS)
if bars is None or len(bars) < max(EMA_SLOW + 2, PIVOT_WINDOW * 2 + 2):
return
closes = [float(b["close"]) for b in bars]
opens = [float(b["open"]) for b in bars]
highs = [float(b["high"]) for b in bars]
lows = [float(b["low"]) for b in bars]
n = len(bars); w = PIVOT_WINDOW; signals = []
ef = _ema(closes, EMA_FAST)
es = _ema(closes, EMA_SLOW)
if ef[-2] < es[-2] and ef[-1] > es[-1]:
signals.append(("bullish", f"EMA{EMA_FAST}/{EMA_SLOW}"))
if ef[-2] > es[-2] and ef[-1] < es[-1]:
signals.append(("bearish", f"EMA{EMA_FAST}/{EMA_SLOW}"))
if (closes[-2] < opens[-2] and closes[-1] > opens[-1]
and closes[-1] > opens[-2] and opens[-1] < closes[-2]):
signals.append(("bullish", "Bullish Engulfing"))
if (closes[-2] > opens[-2] and closes[-1] < opens[-1]
and closes[-1] < opens[-2] and opens[-1] > closes[-2]):
signals.append(("bearish", "Bearish Engulfing"))
for i in range(n - w - 2, n - 1):
h = highs[i]
if (all(h > highs[j] for j in range(max(0, i - w), i)) and
all(h > highs[j] for j in range(i + 1, min(n, i + w + 1)))):
signals.append(("bearish", f"Swing-High (Bar -{n - 1 - i})"))
break
for i in range(n - w - 2, n - 1):
l = lows[i]
if (all(l < lows[j] for j in range(max(0, i - w), i)) and
all(l < lows[j] for j in range(i + 1, min(n, i + w + 1)))):
signals.append(("bullish", f"Swing-Low (Bar -{n - 1 - i})"))
break
bull = [r for d, r in signals if d == "bullish"]
bear = [r for d, r in signals if d == "bearish"]
direction = None; reasons = []
if len(bull) >= 2:
direction = "bullish"; reasons = bull
elif len(bear) >= 2:
direction = "bearish"; reasons = bear
with self._lock:
self.result = direction
self.reasons = reasons
def snapshot(self):
with self._lock:
return self.result, list(self.reasons)
class SRDetector:
"""
Findet horizontale Support-/Resistance-Zonen und Trendlinien auf M15.
Algorithmus:
1. Swing-Pivots erkennen (lokale Hochs/Tiefs mit Fenster ±SR_PIVOT_WIN)
2. Pivots clustern: Preise innerhalb 0.5×ATR werden zu einer Zone
3. Zonen mit >= SR_MIN_TOUCHES Berührungen → gültiges S/R-Level
4. Trendlinien: lineare Regression durch jüngste Pivot-Lows/Highs
"""
SR_DETECT_INTERVAL_S = 60
def __init__(self, symbol: str):
self.symbol = symbol
self.supports = []
self.resistances = []
self.trendline_up = None
self.trendline_dn = None
self._lock = threading.Lock()
self._last_detect_ts: float = 0
@staticmethod
def _find_pivots(highs, lows, win):
n = len(highs)
ph, pl = [], []
for i in range(win, n - win):
h = highs[i]; l = lows[i]
if (all(h >= highs[j] for j in range(i - win, i)) and
all(h >= highs[j] for j in range(i + 1, i + win + 1))):
ph.append((i, h))
if (all(l <= lows[j] for j in range(i - win, i)) and
all(l <= lows[j] for j in range(i + 1, i + win + 1))):
pl.append((i, l))
return ph, pl
@staticmethod
def _atr(highs, lows, closes, period=14):
if len(highs) < period + 1:
return None
trs = []
for i in range(1, len(highs)):
tr = max(highs[i] - lows[i],
abs(highs[i] - closes[i - 1]),
abs(lows[i] - closes[i - 1]))
trs.append(tr)
return sum(trs[-period:]) / period
@staticmethod
def _cluster(pivots, tolerance):
if not pivots:
return []
prices = sorted(p[1] for p in pivots)
clusters = []
current = [prices[0]]
for p in prices[1:]:
if abs(p - current[-1]) <= tolerance:
current.append(p)
else:
clusters.append(current)
current = [p]
clusters.append(current)
return [(sum(c) / len(c), len(c)) for c in clusters]
@staticmethod
def _trendline(pivots_recent):
if len(pivots_recent) < TL_MIN_PIVOTS:
return None
xs = [p[0] for p in pivots_recent]
ys = [p[1] for p in pivots_recent]
n = len(xs)
xm = sum(xs) / n
ym = sum(ys) / n
num = sum((xs[i] - xm) * (ys[i] - ym) for i in range(n))
den = sum((xs[i] - xm) ** 2 for i in range(n))
if den == 0:
return None
slope = num / den
intercept = ym - slope * xm
x0 = xs[0]; x1 = xs[-1]
return {
"start_idx": x0,
"end_idx": x1,
"start_price": slope * x0 + intercept,
"end_price": slope * x1 + intercept,
"slope": slope,
}
def detect(self):
now = time.monotonic()
if now - self._last_detect_ts < self.SR_DETECT_INTERVAL_S:
return
self._last_detect_ts = now
bars = mt5.copy_rates_from_pos(
self.symbol, mt5.TIMEFRAME_M15, 0, SR_LOOKBACK)
if bars is None or len(bars) < 2 * SR_PIVOT_WIN + 5:
return
highs = [float(b["high"]) for b in bars]
lows = [float(b["low"]) for b in bars]
closes = [float(b["close"]) for b in bars]
atr = self._atr(highs, lows, closes)
tol = (atr or (max(highs) - min(lows)) / 50) * SR_TOL_ATR_FACTOR
ph, pl = self._find_pivots(highs, lows, SR_PIVOT_WIN)
zones_high = [(p, n) for p, n in self._cluster(ph, tol)
if n >= SR_MIN_TOUCHES]
zones_low = [(p, n) for p, n in self._cluster(pl, tol)
if n >= SR_MIN_TOUCHES]
cur = closes[-1]
resistances = sorted(
[{"price": p, "touches": n} for p, n in zones_high if p > cur],
key=lambda z: z["price"])[:SR_MAX_LINES]
supports = sorted(
[{"price": p, "touches": n} for p, n in zones_low if p < cur],
key=lambda z: -z["price"])[:SR_MAX_LINES]
recent_cutoff = len(bars) - 50
recent_lows = [p for p in pl if p[0] >= recent_cutoff]
recent_highs = [p for p in ph if p[0] >= recent_cutoff]
tl_up = self._trendline(recent_lows[-3:]) if len(recent_lows) >= 2 else None
tl_dn = self._trendline(recent_highs[-3:]) if len(recent_highs) >= 2 else None
if tl_up and tl_up["slope"] <= 0:
tl_up = None
if tl_dn and tl_dn["slope"] >= 0:
tl_dn = None
n_bars = len(bars)
offset = n_bars - CHART_BARS
def _remap(tl):
if tl is None:
return None
si = tl["start_idx"] - offset
ei = tl["end_idx"] - offset
if tl["slope"] is not None and ei < CHART_BARS - 1:
extra = (CHART_BARS - 1) - tl["end_idx"]
ep = tl["end_price"] + tl["slope"] * extra
ei = CHART_BARS - 1
else:
ep = tl["end_price"]
if si < 0:
sp = tl["start_price"] + tl["slope"] * (offset - tl["start_idx"])
si = 0
else:
sp = tl["start_price"]
return {"start_idx": si, "end_idx": ei,
"start_price": sp, "end_price": ep}
with self._lock:
self.supports = supports
self.resistances = resistances
self.trendline_up = _remap(tl_up)
self.trendline_dn = _remap(tl_dn)
def snapshot(self):
with self._lock:
return {
"supports": list(self.supports),
"resistances": list(self.resistances),
"trendline_up": dict(self.trendline_up) if self.trendline_up else None,
"trendline_dn": dict(self.trendline_dn) if self.trendline_dn else None,
}
+76
View File
@@ -0,0 +1,76 @@
"""
core/analysis/news.py — Keyword-basiertes News-Sentiment
"""
from __future__ import annotations
# Bullische Phrasen für WTI (Angebot ↓ / Nachfrage ↑ / Risiko ↑)
NEWS_BULLISH_KW = {
"supply cut", "production cut", "opec cut", "opec+ cut", "output cut",
"sanction", "embargo", "ban on", "blockade", "shutdown", "outage",
"disruption", "halt", "force majeure", "pipeline attack",
"attack", "strike on", "missile", "drone strike", "tension escalat",
"iran tension", "houthi", "red sea", "strait of hormuz", "war",
"conflict escalat", "retaliat", "threat",
"demand growth", "demand surge", "demand rise", "demand strong",
"stockpile draw", "inventory draw", "stocks drop", "stocks fall",
"stockpiles fall", "crude draw", "cushing draw", "eia draw",
"pipeline shutdown", "refinery fire", "gulf of mexico storm",
"hurricane", "winter storm", "cold snap",
"oil surge", "oil rally", "oil jump", "oil soar", "oil spike",
"crude rise", "crude rally", "wti rise", "wti surge", "wti rally",
}
# Bärische Phrasen für WTI (Angebot ↑ / Nachfrage ↓ / Entspannung)
NEWS_BEARISH_KW = {
"supply glut", "oversupply", "production increase", "output rise",
"production boost", "opec boost", "opec+ unwind", "spr release",
"strategic reserve release", "saudi increase",
"us production record", "shale boom", "permian growth",
"demand drop", "demand fall", "demand weak", "demand slump",
"recession", "slowdown", "weak economy", "china slowdown",
"stockpile build", "inventory build", "stocks rise", "stocks build",
"crude build", "stockpiles rise", "cushing build", "eia build",
"ceasefire", "truce", "deal reached", "agreement", "diplomatic",
"talks resume", "easing tension", "sanction lift", "sanction relief",
"oil drop", "oil plunge", "oil slide", "oil fall", "oil decline",
"crude drop", "crude plunge", "wti fall", "wti slide", "oil crash",
}
def calc_news_sentiment(headlines: list, half_life_hours: float = 8.0) -> dict:
"""
Bewertet Headlines per Keyword-Matching mit altersgewichtetem Decay.
score: -1.0 (klar bärisch) … +1.0 (klar bullisch)
"""
import time as _time
if not headlines:
return {"score": 0.0, "n_bull": 0.0, "n_bear": 0.0, "samples": []}
now = _time.time()
bull_w = bear_w = 0.0
samples = []
for h in headlines:
text = (h.get("title_original") or h.get("title") or "").lower()
if not text:
continue
age_h = max(0.0, (now - h.get("ts", now)) / 3600.0)
weight = 0.5 ** (age_h / max(1.0, half_life_hours))
b = sum(1 for kw in NEWS_BULLISH_KW if kw in text)
s = sum(1 for kw in NEWS_BEARISH_KW if kw in text)
if b > s:
bull_w += weight * (1 + 0.3 * (b - 1))
samples.append(("bull", h.get("title", "")[:70]))
elif s > b:
bear_w += weight * (1 + 0.3 * (s - 1))
samples.append(("bear", h.get("title", "")[:70]))
total = bull_w + bear_w
score = 0.0 if total < 0.1 else (bull_w - bear_w) / total
return {
"score": max(-1.0, min(1.0, score)),
"n_bull": round(bull_w, 1),
"n_bear": round(bear_w, 1),
"samples": samples[:5],
}
+100
View File
@@ -0,0 +1,100 @@
"""
core/candle_logger.py — M1-Candle-Logger (Daten-Sammlung, KEIN Strategie-Eingriff)
==================================================================================
Schreibt **abgeschlossene** M1-Kerzen (OHLC + Spread + Tick-Volumen) in eine eigene
SQLite-Tabelle, damit über die Zeit ein tiefer M1-Datensatz entsteht — die
Broker-M1-History ist kurz und rollt weg. M1 ist die Master-Auflösung: daraus lässt
sich jede höhere TF (M5/M15/M30/H1) exakt aggregieren.
Zeitbasis: `time` = ROHE MT5-Bar-Zeit (**Broker/UTC+3**), wie `copy_rates_from_pos`
sie liefert — deckungsgleich mit allen Backtests. Nur ABGESCHLOSSENE Bars (die letzte,
noch offene Kerze wird nie geschrieben). Idempotent via PRIMARY KEY + INSERT OR IGNORE,
self-healing (jeder Fetch backfillt die letzten N Bars).
"""
from __future__ import annotations
import sqlite3
import threading
import time
import MetaTrader5 as mt5
from core.mt5_utils import mt5_lock
from core.logger import get_logger
log = get_logger("candles")
_REFRESH_S = 55.0 # M1 ändert sich je 60 s → ~55-s-Takt (kein Sub-Minuten-Fetch)
_FETCH_N = 180 # je Fetch die letzten N M1-Bars (backfillt Lücken bis ~3 h)
_STARTUP_N = 3000 # erster Lauf: tiefer holen (~2 Handelstage Backfill nach Restart)
class CandleLogger:
def __init__(self, db_path: str):
self.db_path = db_path
self._last = 0.0
self._first = True
self._lock = threading.Lock()
self._ensure()
def _ensure(self):
try:
con = sqlite3.connect(self.db_path, timeout=5.0)
con.execute("""CREATE TABLE IF NOT EXISTS candles_m1 (
time INTEGER PRIMARY KEY, -- Broker-Epoch (UTC+3), rohe MT5-Bar-Zeit
o REAL, h REAL, l REAL, c REAL,
spread REAL, -- in Preis (spread_points × point)
tick_volume INTEGER,
symbol TEXT )""")
con.commit(); con.close()
except Exception as e:
log.warning(f"candles_m1 anlegen: {e}")
def log(self, sym: str):
"""Im Trend-Loop aufgerufen; self-throttled auf ~55 s. Holt die letzten N
M1-Bars und schreibt die abgeschlossenen (INSERT OR IGNORE = dedupliziert)."""
if not sym:
return
now = time.time()
with self._lock:
if now - self._last < _REFRESH_S:
return
n = _STARTUP_N if self._first else _FETCH_N
try:
with mt5_lock(timeout=2) as got:
if not got:
return
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M1, 0, n)
si = mt5.symbol_info(sym); point = si.point if si else 0.01
if bars is None or len(bars) < 2:
return
# letzte Bar ist noch OFFEN → weglassen (nur abgeschlossene schreiben)
rows = [(int(b["time"]), float(b["open"]), float(b["high"]), float(b["low"]),
float(b["close"]), round(float(b["spread"]) * point, 5),
int(b["tick_volume"]), sym) for b in bars[:-1]]
con = sqlite3.connect(self.db_path, timeout=5.0)
before = con.execute("SELECT COUNT(*) FROM candles_m1").fetchone()[0]
con.executemany(
"INSERT OR IGNORE INTO candles_m1 (time,o,h,l,c,spread,tick_volume,symbol) "
"VALUES (?,?,?,?,?,?,?,?)", rows)
con.commit()
ins = con.execute("SELECT COUNT(*) FROM candles_m1").fetchone()[0] - before
con.close()
with self._lock:
self._last = now
was_first = self._first
self._first = False
if was_first:
log.info(f"M1-Candle-Logger: Start-Backfill +{ins} Bars ({sym})")
elif ins > 0:
log.debug(f"M1-Candles: +{ins}")
except Exception as e:
log.warning(f"candle log: {e}")
def stats(self) -> dict:
try:
con = sqlite3.connect(self.db_path, timeout=5.0)
r = con.execute("SELECT COUNT(*), MIN(time), MAX(time) FROM candles_m1").fetchone()
con.close()
return {"n": r[0] or 0, "first": r[1], "last": r[2]}
except Exception:
return {"n": 0}
+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}")
+159
View File
@@ -0,0 +1,159 @@
"""
core/daily_levels.py — Tägliche WTI-Intraday-Level per Web-Recherche
=====================================================================
Holt einmal morgens (Engine-Scheduler) über **z.ai/GLM mit Web-Suche**
(`web_search`-Tool, Thinking aus) die heutigen Schlüssel-Level (Support /
Widerstandsband / Bias) für WTI und liefert sie als **Kontext**: fließen in
S/R-Anzeige, Close-Alarme und Verdict — NICHT in die Handelsrichtung
(Dritt-Prognose, kein gemessener Edge).
Strenge Plausibilitätsprüfung gegen den aktuellen Kurs verhindert, dass
halluzinierte Zahlen als Zonen landen (fail-safe: bei Zweifel kein Update).
"""
from __future__ import annotations
import threading
import time
from core.logger import get_logger
log = get_logger("daily")
_MAX_DEV = 0.12 # Level muss innerhalb ±12 % des Kurses liegen (sonst verworfen)
_PROMPT = (
"Du bist Rohstoff-Marktanalyst. Suche im Web die heutige INTRADAY-Lage für "
"WTI-Rohöl (US Crude, aktueller Kurs ~{price:.2f} USD). Nenne die für HEUTE "
"wichtigsten technischen Level rund um den aktuellen Kurs.\n\n"
"Antworte AUSSCHLIESSLICH in genau diesem Format (nur Zahlen, Punkt als "
"Dezimaltrenner, deutsche Sprache):\n"
"SUPPORT: <preis unter dem Kurs>\n"
"RESISTANCE_LOW: <preis über dem Kurs>\n"
"RESISTANCE_HIGH: <preis über dem Kurs, >= RESISTANCE_LOW>\n"
"BIAS: bullish|bearish|neutral\n"
"SUMMARY: <1-2 Sätze, nur preisbewegende Faktoren>\n\n"
"Die Level müssen NAHE am aktuellen Kurs liegen (Intraday, keine Jahresziele). "
"Keine Disclaimer, keine Einleitung."
)
class DailyLevels:
def __init__(self, api_key: str, base_url: str = "https://api.z.ai/api/paas/v4",
model: str = "glm-4.5-flash"):
self.api_key = (api_key or "").strip()
self.base_url = (base_url or "https://api.z.ai/api/paas/v4").rstrip("/")
self.model = (model or "glm-4.5-flash").strip()
self.support: float | None = None
self.res_lo: float | None = None
self.res_hi: float | None = None
self.bias: str = "neutral"
self.summary: str = ""
self.ts: float = 0.0
self.error: str | None = None
self._lock = threading.Lock()
def is_configured(self) -> bool:
return bool(self.api_key)
@staticmethod
def _num(line: str):
s = line.split(":", 1)[1] if ":" in line else line
s = s.replace(",", ".")
buf = ""
for c in s:
if c.isdigit() or c == ".":
buf += c
elif buf:
break
try:
return float(buf)
except ValueError:
return None
def _call_llm(self, prompt: str) -> str:
"""z.ai/GLM (OpenAI-kompatibel) mit Web-Suche + Thinking AUS (sonst geht
das Token-Budget komplett ins „Denken")."""
import requests
resp = requests.post(
self.base_url + "/chat/completions",
headers={"Authorization": "Bearer " + self.api_key},
json={"model": self.model,
"messages": [{"role": "user", "content": prompt}],
"tools": [{"type": "web_search",
"web_search": {"enable": True, "search_result": True}}],
"thinking": {"type": "disabled"},
"max_tokens": 500},
timeout=90)
resp.raise_for_status()
return (resp.json()["choices"][0]["message"].get("content") or "").strip()
def run(self, price: float) -> bool:
"""Holt + validiert die Tages-Level. price = aktueller Kurs (Anker +
Plausibilitätsbasis). True bei erfolgreichem, plausiblem Update."""
if not self.is_configured():
with self._lock:
self.error = "Kein z.ai-Key"
return False
if not price or price <= 0:
with self._lock:
self.error = "Kein Kurs als Anker"
return False
try:
text = self._call_llm(_PROMPT.format(price=price))
sup = rlo = rhi = None
bias, summary = "neutral", ""
for raw in text.split("\n"):
low = raw.strip().lower()
if low.startswith("support:"): sup = self._num(raw)
elif low.startswith("resistance_low:"): rlo = self._num(raw)
elif low.startswith("resistance_high:"): rhi = self._num(raw)
elif low.startswith("bias:"):
v = low.split(":", 1)[1]
bias = ("bullish" if "bull" in v else
"bearish" if "bear" in v else "neutral")
elif low.startswith("summary:"):
summary = raw.split(":", 1)[1].strip()
ok, why = self._validate(price, sup, rlo, rhi)
if not ok:
with self._lock:
self.error = f"unplausibel: {why}"
log.warning(f"Tages-Level verworfen ({why}) — "
f"sup={sup} rlo={rlo} rhi={rhi} bei Kurs {price:.2f}")
return False
with self._lock:
self.support, self.res_lo, self.res_hi = sup, rlo, rhi
self.bias, self.summary = bias, summary
self.ts, self.error = time.time(), None
log.info(f"Tages-Level: Sup {sup:.2f} · Res {rlo:.2f}-{rhi:.2f} · "
f"Bias {bias} · {summary[:70]}")
return True
except Exception as e:
with self._lock:
self.error = str(e)[:120]
log.warning(f"DailyLevels.run: {e}")
return False
def _validate(self, price, sup, rlo, rhi):
if sup is None or rlo is None or rhi is None:
return False, "Level fehlen"
if not (sup < price < rhi):
return False, "Reihenfolge support<Kurs<resistance verletzt"
if rlo > rhi:
return False, "res_low>res_high"
for lv in (sup, rlo, rhi):
if abs(lv - price) / price > _MAX_DEV:
return False, f"{lv} >{_MAX_DEV*100:.0f}% vom Kurs entfernt"
return True, ""
def zone_lines(self) -> list[float]:
with self._lock:
return [x for x in (self.support, self.res_lo, self.res_hi)
if x is not None]
def snapshot(self) -> dict:
with self._lock:
return {"support": self.support, "res_lo": self.res_lo,
"res_hi": self.res_hi, "bias": self.bias,
"summary": self.summary, "ts": self.ts, "error": self.error}
+251
View File
@@ -0,0 +1,251 @@
"""
core/elliott.py — Elliott-Wave-/FVG-Heuristik
==============================================
Prinzipienbasierter (NICHT perfekter) Elliott-Wave-Motor als Analyse-Input
für den KI-Agenten. EW-Zählung ist diskretionär — dieser Motor liefert eine
*plausible* Zählung mit Validitäts-Flag, keine Gewissheit.
Was er macht:
1. ATR-ZigZag → Swing-Pivots (H/L) und Legs.
2. Impuls-Erkennung: 5 Legs als 1-2-3-4-5, geprüft gegen die drei harten
EW-Regeln (W2 < Start, W3 nicht der kürzeste, W4 ohne W1-Überlappung).
3. Stand im Zyklus: vollständiger Impuls (→ Reversal-Watch) oder laufende
Welle 5/3 (→ Fib-Extension-Ziel projizieren).
4. FVG-Erkennung (3-Kerzen-Imbalance) der jüngsten unfilled Gaps.
5. Erschöpfungs-Flag, wenn der Kurs das projizierte Ziel erreicht/überschritten hat.
Output via snapshot() — wird in den Agent-Kontext gegeben, damit das LLM mit
EW-Struktur (Zielzone, Erschöpfung, FVG) argumentiert.
"""
from __future__ import annotations
import threading
import time
import MetaTrader5 as mt5
from core.mt5_utils import mt5_lock
from core.logger import get_logger
log = get_logger("elliott")
_N_BARS = 240
_ATR_PERIOD = 14
_ZZ_ATR = 0.7 # Swing-Umkehr ab dieser ATR-Bewegung (etwas grober als wave_rec)
_FVG_LOOKBACK = 60 # Kerzen, in denen nach offenen FVGs gesucht wird
_STALE_S = 180
_TF_LABELS = {
mt5.TIMEFRAME_M1: "M1", mt5.TIMEFRAME_M5: "M5", mt5.TIMEFRAME_M15: "M15",
mt5.TIMEFRAME_M30: "M30", mt5.TIMEFRAME_H1: "H1", mt5.TIMEFRAME_H4: "H4",
}
def _atr(highs, lows, closes, period=_ATR_PERIOD):
trs = [max(highs[i] - lows[i], abs(highs[i] - closes[i - 1]),
abs(lows[i] - closes[i - 1])) for i in range(1, len(highs))]
return (sum(trs[-period:]) / min(len(trs), period)) if trs else None
def _zigzag(highs, lows, thr):
"""ATR-ZigZag → Liste (idx, price, kind 'H'/'L'), chronologisch."""
n = len(highs)
pivots = []
direction = 0
hi_idx, hi = 0, highs[0]
lo_idx, lo = 0, lows[0]
for i in range(1, n):
if highs[i] > hi:
hi, hi_idx = highs[i], i
if lows[i] < lo:
lo, lo_idx = lows[i], i
if direction >= 0 and hi - lows[i] >= thr:
pivots.append((hi_idx, hi, "H")); direction = -1
lo, lo_idx = lows[i], i
elif direction <= 0 and highs[i] - lo >= thr:
pivots.append((lo_idx, lo, "L")); direction = 1
hi, hi_idx = highs[i], i
return pivots
def _detect_fvg(highs, lows, lookback=_FVG_LOOKBACK):
"""Fair Value Gaps (3-Kerzen-Imbalance) der jüngsten Kerzen, noch offen.
Bullish FVG: low[i] > high[i-2] (Lücke nach oben).
Bearish FVG: high[i] < low[i-2] (Lücke nach unten)."""
n = len(highs)
cur = (highs[-1] + lows[-1]) / 2.0
out = []
for i in range(max(2, n - lookback), n):
if lows[i] > highs[i - 2]: # bullish FVG (Support unter dem Kurs)
lo, hi = highs[i - 2], lows[i]
if cur >= lo: # noch nicht nach unten durchbrochen
out.append(("bullish", lo, hi))
elif highs[i] < lows[i - 2]: # bearish FVG (Widerstand über dem Kurs)
lo, hi = highs[i], lows[i - 2]
if cur <= hi: # noch nicht nach oben durchbrochen
out.append(("bearish", lo, hi))
if not out:
return None
typ, lo, hi = out[-1] # jüngster offener FVG
return {"type": typ, "low": round(lo, 3), "high": round(hi, 3),
"mid": round((lo + hi) / 2.0, 3)}
def _label_impulse(pivots):
"""Versucht, die letzten Pivots als 5-Wellen-Impuls zu labeln.
Liefert dict mit Zählung + Validität oder None.
Down-Impuls: H L H L H L (W1 L, W2 H, W3 L, W4 H, W5 L)
Up-Impuls spiegelbildlich."""
if len(pivots) < 5:
return None
# bis zu 6 letzte Pivots betrachten
p = pivots[-6:]
prices = [x[1] for x in p]
kinds = [x[2] for x in p]
# Richtung aus dem Muster: beginnt mit H → Down-Impuls, mit L → Up-Impuls
# Wir brauchen alternierende Kinds.
if any(kinds[i] == kinds[i + 1] for i in range(len(kinds) - 1)):
return None # nicht sauber alternierend
down = kinds[0] == "H"
# Indizes der Wellen-Endpunkte (Start=p[0])
# 5 Legs brauchen 6 Pivots; bei 5 Pivots ist W5 noch offen.
have = len(p)
def leg(a, b):
return abs(prices[b] - prices[a])
if have >= 6:
start, w1, w2, w3, w4, w5 = prices[-6:]
L1, L3, L5 = leg(-6, -5), leg(-4, -3), leg(-2, -1)
# EW-Regeln
if down:
r2 = w2 < start # W2-Hoch unter Start
r4 = w4 < w1 # W4-Hoch unter W1-Tief (keine Überlappung)
else:
r2 = w2 > start
r4 = w4 > w1
r3 = L3 >= min(L1, L5) and not (L3 < L1 and L3 < L5) # W3 nicht der kürzeste
valid = r2 and r3 and r4
return {"pattern": "impulse_down" if down else "impulse_up",
"wave": "5", "complete": True, "valid": valid,
"w5_end": round(prices[-1], 3),
"w4_end": round(prices[-2], 3),
"w1_len": round(L1, 3),
"dir": "down" if down else "up"}
else: # 5 Pivots: W4 fertig, W5 läuft noch
start, w1, w2, w3, w4 = prices[-5:]
L1, L3 = leg(-5, -4), leg(-3, -2)
if down:
r2 = w2 < start; r4 = w4 < w1
else:
r2 = w2 > start; r4 = w4 > w1
r3 = L3 >= L1 * 0.6 # W3 mindestens vergleichbar mit W1
valid = r2 and r3 and r4
return {"pattern": "impulse_down" if down else "impulse_up",
"wave": "5", "complete": False, "valid": valid,
"w4_end": round(prices[-1], 3), "w1_len": round(L1, 3),
"dir": "down" if down else "up"}
class ElliottAnalyzer:
def __init__(self, timeframe: int = mt5.TIMEFRAME_M15):
self._lock = threading.Lock()
self._tf = timeframe
self._snap: dict = {}
self._ts: float = 0.0
self._error: str | None = None
def set_timeframe(self, tf: int):
with self._lock:
self._tf = tf
self._snap = {}
self._ts = 0.0
def refresh_market(self, sym: str):
with self._lock:
tf = self._tf
with mt5_lock(timeout=2) as got:
if not got:
return
bars = mt5.copy_rates_from_pos(sym, tf, 0, _N_BARS)
if bars is None or len(bars) < _ATR_PERIOD + 10:
with self._lock:
self._error = "keine Bars"
return
highs = [float(b["high"]) for b in bars]
lows = [float(b["low"]) for b in bars]
closes = [float(b["close"]) for b in bars]
atr = _atr(highs, lows, closes)
if not atr or atr <= 0:
with self._lock:
self._error = "ATR=0"
return
cur = closes[-1]
pivots = _zigzag(highs, lows, _ZZ_ATR * atr)
fvg = _detect_fvg(highs, lows)
snap = self._build(pivots, cur, atr, fvg, tf)
with self._lock:
self._snap = snap
self._ts = time.time()
self._error = None
# Nur bei ÄNDERUNG loggen — lief vorher je Tick (~6k identische Zeilen
# pro 5-MB-Logrotation) und flutete das Log.
line = (f"{sym} {snap.get('pattern')}/{snap.get('wave')} "
f"target={snap.get('target')} exhausted={snap.get('exhaustion')}")
if line != getattr(self, "_last_log_line", None):
self._last_log_line = line
log.info(line)
def _build(self, pivots, cur, atr, fvg, tf):
tf_lbl = _TF_LABELS.get(tf, str(tf))
out = {"tf": tf_lbl, "pattern": "unclear", "wave": "?",
"dir": None, "target": None, "target_label": None,
"exhaustion": False, "invalidation": None,
"valid": False, "fvg": fvg, "n_pivots": len(pivots),
"note": ""}
imp = _label_impulse(pivots)
if not imp:
out["note"] = "kein sauberer Impuls erkennbar"
return out
out.update(pattern=imp["pattern"], dir=imp["dir"], valid=imp["valid"])
down = imp["dir"] == "down"
if not imp["complete"]:
# Welle 5 läuft → Ziel = W4-Ende ∓ (1.0 / 1.618) × W1-Länge
w4 = imp["w4_end"]; l1 = imp["w1_len"]
t100 = w4 - l1 if down else w4 + l1
t162 = w4 - 1.618 * l1 if down else w4 + 1.618 * l1
out["wave"] = "5 (laufend)"
out["target"] = round(t162, 3)
out["target_label"] = "1.618 W5"
out["invalidation"] = round(w4, 3) # über/unter W4 = Zählung fraglich
# Erschöpfung, wenn Kurs das 1.0-Ziel erreicht/überschritten hat
reached = (cur <= t100) if down else (cur >= t100)
out["exhaustion"] = reached
out["note"] = (f"Welle 5 {'abwärts' if down else 'aufwärts'} läuft, "
f"Ziel ~{out['target']} (1.0 bei ~{round(t100,3)})"
+ (" — Ziel erreicht, Reversal-Risiko" if reached else ""))
else:
# Impuls vollständig → Reversal in Gegenrichtung wahrscheinlich
w5 = imp["w5_end"]; l1 = imp["w1_len"]
out["wave"] = "5 (vollendet)"
out["dir"] = imp["dir"]
# Reversal-Ziele = Fib-Retracement des Gesamtimpulses (grob via W1-Länge)
out["target"] = round((w5 + l1) if down else (w5 - l1), 3)
out["target_label"] = "Reversal ~0.3820.618"
out["invalidation"] = round(w5, 3)
# nach Vollendung gilt der Impuls als erschöpft
out["exhaustion"] = True
out["note"] = (f"Impuls {'abwärts' if down else 'aufwärts'} vollendet bei "
f"{w5} → Reversal {'aufwärts' if down else 'abwärts'} wahrscheinlich")
return out
def snapshot(self) -> dict:
with self._lock:
d = dict(self._snap)
d["error"] = self._error
d["last_update"] = self._ts
d["stale"] = (not self._ts) or (time.time() - self._ts > _STALE_S)
return d
+2018
View File
File diff suppressed because it is too large Load Diff
+123
View File
@@ -0,0 +1,123 @@
"""
core/gaps.py — GapAnalyzer
============================
Erkennt ungefüllte Kurslücken (Gaps) im Tageschart (D1) und liefert ihre
Fill-Target-Levels als S/R-Kandidaten (Magnete). Gleiche Rolle wie `daily_levels`
/ `SRDetector`: NUR Kontext/Anzeige + Konfidenz (über `_sr_levels` → Wellen-
Konfidenz → Autotrader), KEINE eigene Handelsrichtung.
Gap-Definition (Vakuum zwischen Vortag und Folgetag):
UP : Low(heute) > High(gestern) → Lücke [High_gestern … Low_heute], füllt bei High_gestern
DOWN : High(heute) < Low(gestern) → Lücke [High_heute … Low_gestern], füllt bei Low_gestern
„Gefüllt" = ein späterer Bar handelt wieder in/durch das Vakuum.
"""
from __future__ import annotations
import threading
import time
import datetime as dt
import MetaTrader5 as mt5
from core.mt5_utils import mt5_lock
from core.logger import get_logger
log = get_logger("gaps")
_BROKER_OFFSET_S = 3 * 3600 # Brokerzeit (UTC+3) → ~UTC für die Datums-Zuordnung
class GapAnalyzer:
"""Thread-sicher, fail-safe. `maybe_refresh()` drosselt den MT5-Zugriff."""
def __init__(self, refresh_s: int = 600, lookback_days: int = 400,
year: int | None = None):
self._lock = threading.Lock()
self._gaps: list[dict] = [] # offene (ungefüllte) Gaps
self._n_total = 0
self._ts = 0.0
self._next_run = 0.0
self._refresh_s = refresh_s
self._lookback = lookback_days
self._year = year # None = aktuelles Jahr
def maybe_refresh(self, sym: str | None):
"""Im Loop aufrufen — holt die D1-Bars höchstens alle `refresh_s`."""
if not sym or time.time() < self._next_run:
return
self._next_run = time.time() + self._refresh_s
self.detect(sym)
def detect(self, sym: str):
try:
with mt5_lock(timeout=3) as got:
if not got:
self._next_run = time.time() + 30 # gleich nochmal versuchen
return
bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_D1, 0, self._lookback)
if bars is None or len(bars) < 2:
return
year = self._year or dt.datetime.now().year
rows = []
for b in bars:
d = dt.datetime.fromtimestamp(
int(b["time"]) - _BROKER_OFFSET_S, tz=dt.timezone.utc).date()
rows.append({"date": d, "h": float(b["high"]),
"l": float(b["low"]), "c": float(b["close"])})
rows = [r for r in rows if r["date"].year == year]
if len(rows) < 2:
return
gaps = []
for i in range(1, len(rows)):
p, cur = rows[i - 1], rows[i]
if cur["l"] > p["h"]:
gaps.append({"i": i, "date": cur["date"].isoformat(), "dir": "up",
"lo": round(p["h"], 3), "hi": round(cur["l"], 3),
"fill": round(p["h"], 3)})
elif cur["h"] < p["l"]:
gaps.append({"i": i, "date": cur["date"].isoformat(), "dir": "down",
"lo": round(cur["h"], 3), "hi": round(p["l"], 3),
"fill": round(p["l"], 3)})
openg = []
for g in gaps:
later = rows[g["i"] + 1:]
if g["dir"] == "up":
mn = min((r["l"] for r in later), default=g["hi"])
filled = mn <= g["lo"]
else:
mx = max((r["h"] for r in later), default=g["lo"])
filled = mx >= g["hi"]
g["size"] = round(g["hi"] - g["lo"], 3)
if not filled:
g.pop("i", None)
openg.append(g)
with self._lock:
self._gaps = openg
self._n_total = len(gaps)
self._ts = time.time()
log.info(f"Gaps {year}: {len(gaps)} gesamt, {len(openg)} offen")
except Exception as e:
log.warning(f"GapAnalyzer.detect: {e}")
def zone_lines(self) -> list[float]:
"""Fill-Target-Levels (für die S/R-Kandidaten / Magnete)."""
with self._lock:
return [g["fill"] for g in self._gaps]
def nearest(self, price: float | None) -> dict:
"""Nächstes offenes Gap-Fill-Level über/unter dem Preis."""
if price is None:
return {"above": None, "below": None}
with self._lock:
fills = [g["fill"] for g in self._gaps]
above = sorted(f for f in fills if f > price)
below = sorted((f for f in fills if f < price), reverse=True)
return {"above": above[0] if above else None,
"below": below[0] if below else None}
def snapshot(self) -> dict:
with self._lock:
return {"gaps": list(self._gaps), "n_open": len(self._gaps),
"n_total": self._n_total, "last_update": self._ts}
+868
View File
@@ -0,0 +1,868 @@
"""
core/history.py — SQLite-basierter Verlaufs-Logger
====================================================
Speichert Trades, KI-Analysen, Empfehlungen und Signale in einer lokalen
SQLite-Datenbank für spätere statistische Auswertung.
Tabellen:
trades — jeder geöffnete + geschlossene Trade
ai_analyses — jede ChatGPT-Antwort
recommendations — Algorithm-Empfehlungen (gefiltert: alle 60 s)
signals — Reversal, Breakout, EMA-Cross etc.
Alle Schreiboperationen sind thread-safe und werden in einem internen
Lock serialisiert. Lese-Queries (für Stats) können parallel laufen.
Wichtig: Diese Klasse macht KEIN Machine Learning. Sie loggt nur.
Auswertung passiert separat über die `stats_*`-Methoden.
"""
from __future__ import annotations
import sqlite3
import threading
import time
import json
from pathlib import Path
from datetime import datetime, timedelta
# history sendet selbst kein Telegram mehr — Close-Pushes laufen über die Engine
# (Flip-Close-Alarm / Notfall-/Gewinn-Auto-Close).
SCHEMA = [
"""
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticket INTEGER UNIQUE,
symbol TEXT,
direction TEXT, -- 'BUY' | 'SELL'
lots REAL,
entry_time INTEGER, -- unix timestamp (s)
entry_price REAL,
sl_at_entry REAL,
tp_at_entry REAL,
exit_time INTEGER,
exit_price REAL,
pnl REAL,
closed_by TEXT, -- 'manual' | 'sl' | 'tp' | 'trail' | 'unknown'
ai_sentiment TEXT, -- KI-Bewertung beim Einstieg
ai_confidence INTEGER, -- 0-100
rec_signal TEXT, -- 'LONG' | 'SHORT' | 'WARTEN'
rec_score REAL, -- -1.0 .. +1.0
setup TEXT, -- 'TREND_PULLBACK_LONG' | 'BREAKOUT_SHORT' | ...
regime TEXT, -- 'trend_up' | 'trend_down' | 'range' | 'transition'
rsi_at_entry REAL, -- RSI(14) M15 beim Einstieg
news_score REAL -- News-Sentiment -1..+1 beim Einstieg
)
""",
"""
CREATE TABLE IF NOT EXISTS ai_analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER,
sentiment TEXT,
confidence INTEGER,
summary TEXT,
drivers_json TEXT,
cost_estimate REAL,
model TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS recommendations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER,
signal TEXT,
score REAL,
conf_pct INTEGER,
angle_m5 REAL,
angle_m15 REAL,
angle_m30 REAL,
angle_h1 REAL,
reversal TEXT,
ai_sentiment TEXT,
ai_confidence INTEGER,
setup TEXT,
regime TEXT,
rsi REAL,
news_score REAL
)
""",
"""
CREATE TABLE IF NOT EXISTS intended_trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
open_ts INTEGER,
open_price REAL,
direction TEXT, -- 'BUY' | 'SELL'
sl REAL,
tp REAL,
setup TEXT,
regime TEXT,
rsi REAL,
score REAL,
conf_pct INTEGER,
news_score REAL,
ai_sentiment TEXT,
ai_confidence INTEGER,
close_ts INTEGER, -- NULL = noch offen
close_price REAL,
close_reason TEXT, -- 'sl' | 'tp' | 'flip' | 'expired' | 'disabled'
pnl_pct REAL -- (close-open)/open * 100, vorzeichenrichtig
)
""",
"""
CREATE TABLE IF NOT EXISTS signals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER,
signal_type TEXT, -- 'reversal' | 'breakout' | 'ema_cross'
direction TEXT, -- 'bullish' | 'bearish'
price REAL,
details_json TEXT
)
""",
"""
CREATE TABLE IF NOT EXISTS pbreak_predictions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER, -- lokale Epoch (wie trades/recommendations)
symbol TEXT,
direction TEXT, -- 'LONG' | 'SHORT' (Positionsrichtung)
level REAL,
p_break REAL, -- geglättete P(break) 0..100 beim Touch
predicted TEXT, -- 'break' | 'bounce' (P(break) vs. sr_close_pbreak)
price_at_pred REAL, -- bid beim Touch
confirm_price REAL, -- level ± 0,5×ATR in Richtung Durchbruch
reject_price REAL, -- level ± 0,5×ATR in Richtung Abprall
atr REAL,
outcome TEXT, -- NULL bis ausgewertet, dann 'break' | 'bounce'
outcome_ts INTEGER,
correct INTEGER -- NULL bis ausgewertet, dann 0/1
)
""",
]
# Indizes — werden NACH den Migrationen ausgeführt, weil sie Spalten
# referenzieren, die u.U. erst per ALTER TABLE hinzugefügt werden.
INDEXES = [
"CREATE INDEX IF NOT EXISTS idx_trades_entry ON trades(entry_time)",
"CREATE INDEX IF NOT EXISTS idx_trades_exit ON trades(exit_time)",
"CREATE INDEX IF NOT EXISTS idx_trades_setup ON trades(setup)",
"CREATE INDEX IF NOT EXISTS idx_trades_closed_by ON trades(closed_by)",
"CREATE INDEX IF NOT EXISTS idx_trades_open ON trades(exit_time) WHERE exit_time IS NULL",
"CREATE INDEX IF NOT EXISTS idx_ai_ts ON ai_analyses(timestamp)",
"CREATE INDEX IF NOT EXISTS idx_rec_ts ON recommendations(timestamp)",
"CREATE INDEX IF NOT EXISTS idx_rec_setup ON recommendations(setup)",
"CREATE INDEX IF NOT EXISTS idx_signals_ts ON signals(timestamp)",
"CREATE INDEX IF NOT EXISTS idx_intended_open ON intended_trades(open_ts)",
"CREATE INDEX IF NOT EXISTS idx_intended_close ON intended_trades(close_ts)",
"CREATE INDEX IF NOT EXISTS idx_pbreak_ts ON pbreak_predictions(ts)",
"CREATE INDEX IF NOT EXISTS idx_pbreak_open ON pbreak_predictions(outcome) WHERE outcome IS NULL",
]
# Migrations: Spalten, die in alten DBs evtl. fehlen.
# `ALTER TABLE ADD COLUMN` ist in SQLite idempotent über try/except.
MIGRATIONS = [
("trades", "setup", "TEXT"),
("trades", "regime", "TEXT"),
("trades", "rsi_at_entry", "REAL"),
("trades", "news_score", "REAL"),
("trades", "commission", "REAL"),
("recommendations","setup", "TEXT"),
("recommendations","regime", "TEXT"),
("recommendations","rsi", "REAL"),
("recommendations","news_score", "REAL"),
]
class HistoryLogger:
"""Persistenter SQLite-Logger für Trading-Verlauf."""
def __init__(self, db_path: Path,
rec_min_interval_s: int = 60):
self.db_path = db_path
self._lock = threading.Lock()
self._last_rec_ts = 0 # Throttling für recommendations
self.rec_min_interval = rec_min_interval_s
self._telegram: dict = {"enabled": False, "token": "", "chat_id": ""}
self._last_optimize_ts: float = 0.0
self._init_db()
def configure_telegram(self, *, enabled: bool, token: str, chat_id: str):
"""Telegram-Benachrichtigungen konfigurieren (nach Config-Load aufrufen)."""
self._telegram = {"enabled": enabled, "token": token, "chat_id": chat_id}
# ── Verbindung & Schema ─────────────────
def _connect(self):
# Eine neue Verbindung pro Thread vermeidet SQLite-Threading-Issues.
# Da wir alles unter Lock haben, ist eine Connection auch ok aber wir
# bleiben safer mit Thread-Local Verbindungen.
conn = sqlite3.connect(str(self.db_path), timeout=5.0)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode = WAL")
now = time.time()
if now - self._last_optimize_ts > 86400:
conn.execute("PRAGMA optimize")
self._last_optimize_ts = now
return conn
def _init_db(self):
with self._connect() as conn:
# 1. Tabellen anlegen (nur Spalten, keine Indizes)
for stmt in SCHEMA:
conn.execute(stmt)
# 2. Migrationen: fehlende Spalten in bestehenden DBs nachziehen.
# MUSS vor den CREATE-INDEX-Statements laufen, weil manche
# Indizes auf Spalten zeigen, die erst hier hinzukommen.
for table, col, ctype in MIGRATIONS:
try:
conn.execute(f"ALTER TABLE {table} ADD COLUMN {col} {ctype}")
except sqlite3.OperationalError:
pass # Spalte existiert bereits
# 3. Jetzt sind alle Spalten garantiert da → Indizes anlegen
for stmt in INDEXES:
conn.execute(stmt)
conn.commit()
# ══════════════════════════════════════════
# WRITE-METHODEN
# ══════════════════════════════════════════
def log_trade_open(self, *, ticket: int, symbol: str, direction: str,
lots: float, entry_price: float,
sl_at_entry: float | None = None,
tp_at_entry: float | None = None,
ai_sentiment: str | None = None,
ai_confidence: int | None = None,
rec_signal: str | None = None,
rec_score: float | None = None,
setup: str | None = None,
regime: str | None = None,
rsi_at_entry: float | None = None,
news_score: float | None = None):
"""Loggt das Öffnen eines Trades. Idempotent über UNIQUE(ticket)."""
# Guard (Fix 2026-07-19): 0-Lot-/Preis-lose Einträge sind Reconcile-
# Artefakte (real: Ticket 47966457, 0.0L, closed_by=unknown) — sie
# verfälschen WR/Statistik. Nicht loggen.
if not lots or lots <= 0 or not entry_price or entry_price <= 0:
return
now_ts = int(time.time())
with self._lock, self._connect() as conn:
conn.execute("""
INSERT OR IGNORE INTO trades
(ticket, symbol, direction, lots,
entry_time, entry_price, sl_at_entry, tp_at_entry,
ai_sentiment, ai_confidence, rec_signal, rec_score,
setup, regime, rsi_at_entry, news_score)
VALUES (?,?,?,?, ?,?,?,?, ?,?,?,?, ?,?,?,?)
""", (ticket, symbol, direction, lots,
now_ts, entry_price, sl_at_entry, tp_at_entry,
ai_sentiment, ai_confidence, rec_signal, rec_score,
setup, regime, rsi_at_entry, news_score))
conn.commit()
# Kein Telegram bei Einstieg — nur Ergebnis (Close) wird gesendet
def log_trade_close(self, *, ticket: int, exit_price: float,
pnl: float, closed_by: str = "manual",
exit_ts: int | None = None,
commission: float = 0.0):
"""Schreibt Exit-Daten zu einem bestehenden Trade.
exit_ts kann gesetzt werden für Reconciliation alter Trades.
Sanity-Check: wenn exit_ts < entry_time (kaputter Deal-Lookup),
wird stattdessen time.time() benutzt.
"""
ts = int(exit_ts) if exit_ts else int(time.time())
with self._lock, self._connect() as conn:
# Defensive: exit kann nicht vor entry liegen.
row = conn.execute(
"SELECT entry_time FROM trades "
"WHERE ticket = ? AND exit_time IS NULL", (ticket,)
).fetchone()
if row and row["entry_time"] and ts < row["entry_time"]:
ts = int(time.time())
conn.execute("""
UPDATE trades
SET exit_time = ?, exit_price = ?, pnl = ?,
closed_by = ?, commission = ?
WHERE ticket = ? AND exit_time IS NULL
""", (ts, exit_price, pnl, closed_by, commission, ticket))
conn.commit()
# KEIN Trade-Abschluss-Telegram mehr (User-Vorgabe): Telegram zum Thema
# Schließen kommt nur noch beim echten Signal-Flip (engine._check_close_
# alert „🔔 CLOSE-Signal") sowie bei Notfall-/Gewinn-Auto-Close. Die terse
# „Sym +X"-Bestätigung bei jedem Close ist entfernt.
def log_ai(self, *, sentiment: str, confidence: int,
summary: str, drivers: list,
cost_estimate: float | None,
model: str):
with self._lock, self._connect() as conn:
conn.execute("""
INSERT INTO ai_analyses
(timestamp, sentiment, confidence, summary,
drivers_json, cost_estimate, model)
VALUES (?,?,?,?,?,?,?)
""", (int(time.time()), sentiment, confidence,
summary[:500], json.dumps(drivers, ensure_ascii=False),
cost_estimate, model))
conn.commit()
def log_recommendation(self, *, signal: str, score: float, conf_pct: int,
angles: dict, reversal: str | None,
ai_sentiment: str | None,
ai_confidence: int | None,
setup: str | None = None,
regime: str | None = None,
rsi: float | None = None,
news_score: float | None = None):
"""
Empfehlungs-Logging mit Throttling: nur jede N Sekunden, um die DB
nicht mit identischen Snapshots zu fluten.
"""
now = int(time.time())
if now - self._last_rec_ts < self.rec_min_interval:
return
self._last_rec_ts = now
with self._lock, self._connect() as conn:
conn.execute("""
INSERT INTO recommendations
(timestamp, signal, score, conf_pct,
angle_m5, angle_m15, angle_m30, angle_h1,
reversal, ai_sentiment, ai_confidence,
setup, regime, rsi, news_score)
VALUES (?,?,?,?, ?,?,?,?, ?,?,?, ?,?,?,?)
""", (now, signal, score, conf_pct,
angles.get("M5"), angles.get("M15"),
angles.get("M30"), angles.get("H1"),
reversal, ai_sentiment, ai_confidence,
setup, regime, rsi, news_score))
conn.commit()
def log_signal(self, *, signal_type: str, direction: str,
price: float | None = None, details: dict | None = None):
with self._lock, self._connect() as conn:
conn.execute("""
INSERT INTO signals
(timestamp, signal_type, direction, price, details_json)
VALUES (?,?,?,?,?)
""", (int(time.time()), signal_type, direction, price,
json.dumps(details or {}, ensure_ascii=False)))
conn.commit()
def log_pbreak_prediction(self, *, symbol: str, direction: str, level: float,
p_break: float, predicted: str, price_at_pred: float,
confirm_price: float, reject_price: float,
atr: float) -> int:
"""Loggt EINE Abprall/Durchbruch-Vorhersage bei frischem Level-Touch
(`predicted` = 'break'|'bounce', aus dem live-P(break) vs. `sr_close_pbreak`).
Auswertung passiert separat (`engine._evaluate_pbreak_predictions`, gegen
`candles_m1`). Rückgabe = Zeilen-ID."""
with self._lock, self._connect() as conn:
cur = conn.execute("""
INSERT INTO pbreak_predictions
(ts, symbol, direction, level, p_break, predicted, price_at_pred,
confirm_price, reject_price, atr)
VALUES (?,?,?,?,?,?,?,?,?,?)
""", (int(time.time()), symbol, direction, level, p_break, predicted,
price_at_pred, confirm_price, reject_price, atr))
conn.commit()
return cur.lastrowid
def pbreak_accuracy(self, period: str = "all") -> dict:
"""Trefferquote der Abprall/Durchbruch-Prognose (nur ausgewertete Zeilen)."""
since, until = self._range_to_ts(period)
with self._connect() as conn:
rows = conn.execute("""
SELECT predicted, outcome, correct FROM pbreak_predictions
WHERE outcome IS NOT NULL AND ts BETWEEN ? AND ?
""", (since, until)).fetchall()
pending = conn.execute(
"SELECT COUNT(*) FROM pbreak_predictions WHERE outcome IS NULL"
).fetchone()[0]
n = len(rows)
n_correct = sum(1 for r in rows if r["correct"])
n_break_pred = sum(1 for r in rows if r["predicted"] == "break")
n_bounce_pred = sum(1 for r in rows if r["predicted"] == "bounce")
n_break_correct = sum(1 for r in rows if r["predicted"] == "break" and r["correct"])
n_bounce_correct = sum(1 for r in rows if r["predicted"] == "bounce" and r["correct"])
return {
"n": n, "n_correct": n_correct,
"accuracy": round(100 * n_correct / n, 1) if n else None,
"n_break_pred": n_break_pred, "n_break_correct": n_break_correct,
"break_accuracy": round(100 * n_break_correct / n_break_pred, 1) if n_break_pred else None,
"n_bounce_pred": n_bounce_pred, "n_bounce_correct": n_bounce_correct,
"bounce_accuracy": round(100 * n_bounce_correct / n_bounce_pred, 1) if n_bounce_pred else None,
"pending": pending,
}
# ══════════════════════════════════════════
# STATISTIK-QUERIES
# ══════════════════════════════════════════
@staticmethod
def _range_to_ts(period: str) -> tuple[int, int]:
"""'today' | 'week' | 'month' | 'all' → (since_ts, now_ts)."""
now = int(time.time())
if period == "today":
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
return (int(today.timestamp()), now)
if period == "yesterday":
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
return (int((today - timedelta(days=1)).timestamp()), int(today.timestamp()))
if period == "week":
since = datetime.now() - timedelta(days=7)
return (int(since.timestamp()), now)
if period == "month":
since = datetime.now() - timedelta(days=30)
return (int(since.timestamp()), now)
return (0, now)
def stats_overview(self, period: str = "all") -> dict:
"""Liefert ein Dict mit den Hauptkennzahlen für den gewählten Zeitraum."""
since, until = self._range_to_ts(period)
with self._connect() as conn:
# Trades (nur abgeschlossene)
rows = conn.execute("""
SELECT direction, lots, entry_time, exit_time, entry_price,
exit_price, pnl, closed_by, ai_sentiment, ai_confidence,
rec_signal
FROM trades
WHERE exit_time IS NOT NULL
AND exit_time BETWEEN ? AND ?
ORDER BY exit_time
LIMIT 10000
""", (since, until)).fetchall()
n = len(rows)
wins = [r for r in rows if (r["pnl"] or 0) > 0]
losses = [r for r in rows if (r["pnl"] or 0) < 0]
breakeven = [r for r in rows if (r["pnl"] or 0) == 0]
total_pnl = sum((r["pnl"] or 0) for r in rows)
avg_win = (sum(r["pnl"] for r in wins) / len(wins)) if wins else 0.0
avg_loss = (sum(r["pnl"] for r in losses) / len(losses)) if losses else 0.0
gross_win = sum(r["pnl"] for r in wins)
gross_loss = sum(-r["pnl"] for r in losses)
profit_factor = (gross_win / gross_loss) if gross_loss > 0 else None
# Closed-by Verteilung
cb_counts = {}
for r in rows:
cb = r["closed_by"] or "unknown"
cb_counts[cb] = cb_counts.get(cb, 0) + 1
# KI-Trefferquote
ai_correct = ai_total = 0
for r in rows:
sent = (r["ai_sentiment"] or "").lower()
pnl = r["pnl"] or 0
direction = (r["direction"] or "").upper()
if sent in ("bullish", "bearish") and pnl != 0:
ai_total += 1
# KI bullish + Long-Trade gewonnen, oder bearish + Short gewonnen
ai_aligned = (
(sent == "bullish" and direction == "BUY" and pnl > 0) or
(sent == "bearish" and direction == "SELL" and pnl > 0) or
(sent == "bullish" and direction == "SELL" and pnl < 0) or
(sent == "bearish" and direction == "BUY" and pnl < 0)
)
if ai_aligned:
ai_correct += 1
# Wochentag-Verteilung
weekday_stats = {i: {"wins": 0, "losses": 0} for i in range(7)}
for r in rows:
wd = datetime.fromtimestamp(r["exit_time"]).weekday()
if (r["pnl"] or 0) > 0:
weekday_stats[wd]["wins"] += 1
elif (r["pnl"] or 0) < 0:
weekday_stats[wd]["losses"] += 1
# Stunden-Verteilung
hour_stats = {h: {"wins": 0, "losses": 0} for h in range(24)}
for r in rows:
hr = datetime.fromtimestamp(r["exit_time"]).hour
if (r["pnl"] or 0) > 0:
hour_stats[hr]["wins"] += 1
elif (r["pnl"] or 0) < 0:
hour_stats[hr]["losses"] += 1
# Top-3 Stunden (mind. 2 Trades)
top_hours = sorted(
[(h, s["wins"], s["losses"]) for h, s in hour_stats.items()
if (s["wins"] + s["losses"]) >= 2],
key=lambda x: (x[1] / max(1, x[1]+x[2]), x[1]+x[2]),
reverse=True)[:3]
return {
"period": period,
"since": since,
"until": until,
"n_trades": n,
"n_wins": len(wins),
"n_losses": len(losses),
"n_be": len(breakeven),
"winrate": (len(wins) / n * 100) if n else 0.0,
"total_pnl": total_pnl,
"avg_win": avg_win,
"avg_loss": avg_loss,
"gross_win": gross_win,
"gross_loss": gross_loss,
"profit_factor": profit_factor,
"closed_by": cb_counts,
"ai_correct": ai_correct,
"ai_total": ai_total,
"ai_winrate": (ai_correct / ai_total * 100) if ai_total else None,
"weekday_stats": weekday_stats,
"hour_stats": hour_stats,
"top_hours": top_hours,
}
def setup_stats(self, period: str = "all") -> list[dict]:
"""
Performance-Aufschlüsselung pro Setup-Typ.
Liefert eine Liste {setup, n, wins, losses, winrate, total_pnl, avg_pnl, profit_factor}
sortiert nach total_pnl absteigend.
"""
since, until = self._range_to_ts(period)
with self._connect() as conn:
rows = conn.execute("""
SELECT setup, pnl
FROM trades
WHERE exit_time IS NOT NULL
AND exit_time BETWEEN ? AND ?
AND setup IS NOT NULL
""", (since, until)).fetchall()
groups: dict[str, list[float]] = {}
for r in rows:
groups.setdefault(r["setup"] or "UNKNOWN", []).append(r["pnl"] or 0.0)
out = []
for setup, pnls in groups.items():
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p < 0]
gross_w = sum(wins)
gross_l = sum(-p for p in losses)
out.append({
"setup": setup,
"n": len(pnls),
"wins": len(wins),
"losses": len(losses),
"winrate": (len(wins) / len(pnls) * 100) if pnls else 0.0,
"total_pnl": sum(pnls),
"avg_pnl": sum(pnls) / len(pnls) if pnls else 0.0,
"gross_win": gross_w,
"gross_loss": gross_l,
"profit_factor": (gross_w / gross_l) if gross_l > 0 else None,
})
out.sort(key=lambda x: x["total_pnl"], reverse=True)
return out
def setup_multipliers(self, *, min_trades: int = 10,
lookback_days: int = 90) -> dict[str, dict]:
"""
Lernt aus historischen Trades einen Confidence-Multiplikator pro Setup.
Mapping:
Profit-Factor 1.0 (break-even) → 1.0 (kein Effekt)
Profit-Factor ≥ 2.0 (sehr profitabel)→ 1.3 (Score +30 %)
Profit-Factor ≤ 0.5 (klar verlierend)→ 0.5 (Score 50 %)
Lineare Interpolation dazwischen.
Setups unter `min_trades` bekommen Multiplikator 1.0 (Neutral, noch zu
wenig Daten). Lookback begrenzt auf `lookback_days` Tage, damit
Regime-Wechsel berücksichtigt werden.
Rückgabe: {setup_name: {"multiplier": float, "n": int,
"profit_factor": float|None, "winrate": float}}
"""
cutoff = int(time.time()) - lookback_days * 86400
with self._connect() as conn:
rows = conn.execute("""
SELECT setup, pnl
FROM trades
WHERE exit_time IS NOT NULL
AND exit_time >= ?
AND setup IS NOT NULL
""", (cutoff,)).fetchall()
groups: dict[str, list[float]] = {}
for r in rows:
groups.setdefault(r["setup"], []).append(r["pnl"] or 0.0)
out: dict[str, dict] = {}
for setup, pnls in groups.items():
n = len(pnls)
wins = [p for p in pnls if p > 0]
losses = [p for p in pnls if p < 0]
gross_w = sum(wins)
gross_l = sum(-p for p in losses)
pf = (gross_w / gross_l) if gross_l > 0 else (
float("inf") if gross_w > 0 else 1.0)
wr = (len(wins) / n * 100) if n else 0.0
if n < min_trades:
mult = 1.0
else:
# Mapping pf → multiplier (geclampt)
pf_capped = min(max(pf, 0.3), 3.0)
if pf_capped >= 1.0:
# 1.0 → 1.0, 2.0 → 1.3, 3.0 → 1.3 (gecapped)
mult = 1.0 + min(0.30, (pf_capped - 1.0) * 0.30)
else:
# 1.0 → 1.0, 0.5 → 0.5, 0.3 → 0.5 (gecapped)
mult = max(0.50, 1.0 - (1.0 - pf_capped) * 1.0)
out[setup] = {
"multiplier": round(mult, 3),
"n": n,
"profit_factor": pf if pf != float("inf") else None,
"winrate": wr,
}
return out
# ══════════════════════════════════════════
# AUTO-TRADE DRY-RUN
# ══════════════════════════════════════════
def intended_open(self, *, direction: str, open_price: float,
sl: float, tp: float,
setup: str, regime: str | None,
rsi: float | None, score: float, conf_pct: int,
news_score: float | None,
ai_sentiment: str | None,
ai_confidence: int | None) -> int:
"""Loggt einen hypothetischen Auto-Trade. Liefert die neue Row-ID."""
with self._lock, self._connect() as conn:
cur = conn.execute("""
INSERT INTO intended_trades
(open_ts, open_price, direction, sl, tp,
setup, regime, rsi, score, conf_pct,
news_score, ai_sentiment, ai_confidence)
VALUES (?,?,?,?,?, ?,?,?,?,?, ?,?,?)
""", (int(time.time()), open_price, direction, sl, tp,
setup, regime, rsi, score, conf_pct,
news_score, ai_sentiment, ai_confidence))
conn.commit()
return cur.lastrowid
def intended_close(self, *, row_id: int, close_price: float,
close_reason: str):
"""Schließt einen hypothetischen Trade. Berechnet pnl_pct vorzeichenrichtig."""
with self._lock, self._connect() as conn:
row = conn.execute(
"SELECT open_price, direction FROM intended_trades "
"WHERE id = ? AND close_ts IS NULL", (row_id,)
).fetchone()
if not row:
return # bereits geschlossen oder nicht vorhanden
op = row["open_price"] or 0.0
if op <= 0:
pnl_pct = 0.0
else:
diff = (close_price - op) if row["direction"] == "BUY" \
else (op - close_price)
pnl_pct = diff / op * 100.0
conn.execute("""
UPDATE intended_trades
SET close_ts = ?, close_price = ?,
close_reason = ?, pnl_pct = ?
WHERE id = ?
""", (int(time.time()), close_price, close_reason, pnl_pct, row_id))
conn.commit()
def intended_open_list(self) -> list[dict]:
"""Liefert alle noch nicht geschlossenen intended trades."""
with self._connect() as conn:
rows = conn.execute("""
SELECT * FROM intended_trades WHERE close_ts IS NULL
ORDER BY open_ts DESC
""").fetchall()
return [dict(r) for r in rows]
def intended_today_count(self) -> int:
"""Anzahl heute geöffneter hypothetischer Trades (für Daily-Cap)."""
from datetime import datetime as _dt
today = _dt.now().replace(hour=0, minute=0, second=0, microsecond=0)
since = int(today.timestamp())
with self._connect() as conn:
return conn.execute(
"SELECT COUNT(*) FROM intended_trades WHERE open_ts >= ?",
(since,)
).fetchone()[0]
def intended_last_close_ts(self) -> int:
"""Timestamp des letzten geschlossenen intended trade (für Cooldown).
Flip-Closes zählen nicht — nach einem Signal-Flip darf die neue
Richtung sofort eröffnet werden (schnelle Breakout-Reaktion)."""
with self._connect() as conn:
row = conn.execute(
"SELECT MAX(close_ts) FROM intended_trades "
"WHERE close_ts IS NOT NULL "
" AND COALESCE(close_reason, '') != 'flip'"
).fetchone()
return int(row[0] or 0)
def intended_summary(self, period: str = "all") -> dict:
"""Performance-Zusammenfassung aller hypothetischen Trades."""
since, until = self._range_to_ts(period)
with self._connect() as conn:
rows = conn.execute("""
SELECT pnl_pct, close_reason, setup FROM intended_trades
WHERE close_ts IS NOT NULL
AND close_ts BETWEEN ? AND ?
""", (since, until)).fetchall()
n = len(rows)
wins = sum(1 for r in rows if (r["pnl_pct"] or 0) > 0)
losses = sum(1 for r in rows if (r["pnl_pct"] or 0) < 0)
total_pnl_pct = sum(r["pnl_pct"] or 0 for r in rows)
avg_pnl_pct = total_pnl_pct / n if n else 0.0
by_reason: dict[str, int] = {}
for r in rows:
by_reason[r["close_reason"] or "?"] = by_reason.get(r["close_reason"] or "?", 0) + 1
return {
"n": n,
"wins": wins,
"losses": losses,
"winrate": (wins / n * 100) if n else 0.0,
"total_pnl_pct": total_pnl_pct,
"avg_pnl_pct": avg_pnl_pct,
"by_reason": by_reason,
}
def last_closed_trades(self, n: int = 5) -> list[dict]:
"""Liefert die letzten n geschlossenen Trades (neueste zuerst)."""
with self._connect() as conn:
rows = conn.execute("""
SELECT direction, lots, entry_time, exit_time,
entry_price, exit_price, pnl, commission, closed_by, setup
FROM trades
WHERE exit_time IS NOT NULL
ORDER BY exit_time DESC
LIMIT ?
""", (n,)).fetchall()
return [dict(r) for r in rows]
def open_trades(self) -> list[dict]:
"""Liefert alle Trades, für die noch kein exit_time eingetragen ist."""
with self._connect() as conn:
rows = conn.execute("""
SELECT id, ticket, symbol, direction, entry_time, entry_price
FROM trades
WHERE exit_time IS NULL
ORDER BY entry_time
""").fetchall()
return [dict(r) for r in rows]
def consistency_report(self) -> dict:
"""
Diagnostiziert Inkonsistenzen in der History-DB.
Liefert ein Dict mit erkannten Problemen:
• n_open_trades_db — Anzahl "offene" Trades in DB
• n_orphan_old — offene Trades älter als 14 Tage (verwaist)
• n_negative_pnl_no_loss — geschlossene Trades mit pnl < 0 aber wins+losses=0
• n_missing_setup — geschlossene Trades ohne setup-Spalte
• n_missing_exit_price — geschlossene Trades ohne exit_price
• duplicate_tickets — Tickets, die mehrfach existieren (sollte 0 sein)
• db_size_mb
• oldest_trade_age_days
"""
now = int(time.time())
out = {
"timestamp": now,
"n_open_trades_db": 0,
"n_orphan_old": 0,
"n_missing_setup": 0,
"n_missing_exit_price": 0,
"n_pnl_zero_closed": 0,
"duplicate_tickets": [],
"db_size_mb": self.db_size_mb(),
"oldest_trade_age_days": None,
}
ORPHAN_AGE_S = 14 * 86400
with self._connect() as conn:
# Open trades
opens = conn.execute(
"SELECT ticket, entry_time FROM trades WHERE exit_time IS NULL"
).fetchall()
out["n_open_trades_db"] = len(opens)
out["n_orphan_old"] = sum(
1 for r in opens if (r["entry_time"] or now) < now - ORPHAN_AGE_S
)
# Geschlossene Trades ohne Setup-Spalte (alte Daten vor Migration)
out["n_missing_setup"] = conn.execute("""
SELECT COUNT(*) FROM trades
WHERE exit_time IS NOT NULL AND (setup IS NULL OR setup = '')
""").fetchone()[0]
# Geschlossene Trades ohne exit_price
out["n_missing_exit_price"] = conn.execute("""
SELECT COUNT(*) FROM trades
WHERE exit_time IS NOT NULL
AND (exit_price IS NULL OR exit_price = 0)
""").fetchone()[0]
# Geschlossene Trades mit pnl = 0 — aber NUR die, die NICHT
# bewusst als 'unknown' markiert sind (Phantom-Cleanup). Trades
# mit closed_by='unknown' AND pnl=0 sind gewollt (uns fehlen
# die echten Exit-Daten), das ist keine Anomalie.
out["n_pnl_zero_closed"] = conn.execute("""
SELECT COUNT(*) FROM trades
WHERE exit_time IS NOT NULL
AND (pnl IS NULL OR pnl = 0)
AND COALESCE(closed_by, '') != 'unknown'
""").fetchone()[0]
# Duplikat-Tickets (UNIQUE-Constraint sollte das verhindern,
# aber wir prüfen trotzdem)
dups = conn.execute("""
SELECT ticket, COUNT(*) c FROM trades
GROUP BY ticket HAVING c > 1
""").fetchall()
out["duplicate_tickets"] = [{"ticket": r["ticket"], "count": r["c"]}
for r in dups]
# Ältester Trade
row = conn.execute(
"SELECT MIN(entry_time) FROM trades"
).fetchone()
if row and row[0]:
out["oldest_trade_age_days"] = (now - row[0]) / 86400.0
return out
def all_trades(self, period: str = "all") -> list[dict]:
"""Vollständige Trade-Liste für CSV-Export."""
since, until = self._range_to_ts(period)
with self._connect() as conn:
rows = conn.execute("""
SELECT * FROM trades
WHERE entry_time BETWEEN ? AND ?
ORDER BY entry_time DESC
""", (since, until)).fetchall()
return [dict(r) for r in rows]
def export_csv(self, file_path: Path, period: str = "all") -> int:
"""Exportiert Trades als CSV. Liefert Anzahl exportierter Zeilen."""
import csv
rows = self.all_trades(period)
if not rows:
return 0
with open(file_path, "w", encoding="utf-8", newline="") as f:
writer = csv.DictWriter(f, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows(rows)
return len(rows)
def db_size_mb(self) -> float:
try:
return self.db_path.stat().st_size / (1024 * 1024)
except Exception:
return 0.0
def n_total_trades(self) -> int:
with self._connect() as conn:
return conn.execute(
"SELECT COUNT(*) FROM trades WHERE exit_time IS NOT NULL"
).fetchone()[0]
+88
View File
@@ -0,0 +1,88 @@
"""
core/logger.py — Zentralisiertes Logging
==========================================
Schreibt sowohl in Konsole als auch in eine rotierende Logdatei.
Wird von allen anderen Modulen über `from core.logger import log` genutzt.
Verwendung:
log.info("Trade BUY 0.5L @ 74.30")
log.warning("Slow API response")
log.error("Order fehlgeschlagen", exc_info=True)
log.debug("Detail-Info") # nur bei DEBUG-Level sichtbar
Vorteile gegenüber print():
- Persistente Datei-Logs (überleben Crashes / pythonw)
- Log-Rotation (max 5 MB pro Datei, 5 Backups)
- Filterbar nach Komponente (logger.getChild('trade'))
- Zeit + Level pro Eintrag
"""
from __future__ import annotations
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
# ── Konfiguration ──────────────────────────────
LOG_FILE = Path(__file__).parent.parent / "oil_widget.log"
LOG_MAX_BYTES = 5_000_000
LOG_BACKUP_COUNT = 5
# Format mit Komponente, Level, Zeit
_FILE_FORMAT = "%(asctime)s [%(levelname)-7s] %(name)-14s | %(message)s"
_CONSOLE_FORMAT = "[%(name)s] %(message)s"
def _setup_logger(level: int = logging.INFO) -> logging.Logger:
"""Erstellt den Root-Logger 'oil' mit Datei- und Konsolen-Handler."""
logger = logging.getLogger("oil")
logger.setLevel(logging.DEBUG) # Detail-Filter erfolgt pro Handler
logger.propagate = False
# Doppelte Handler vermeiden (z.B. bei Reload)
if logger.handlers:
return logger
# Datei-Handler komplettes Detail
try:
fh = RotatingFileHandler(
LOG_FILE, maxBytes=LOG_MAX_BYTES,
backupCount=LOG_BACKUP_COUNT, encoding="utf-8")
fh.setLevel(logging.DEBUG)
fh.setFormatter(logging.Formatter(_FILE_FORMAT,
datefmt="%Y-%m-%d %H:%M:%S"))
logger.addHandler(fh)
except Exception as e:
# Fallback: kein File-Log möglich (z.B. Read-Only-FS)
sys.stderr.write(f"[Logger] Datei-Logging fehlgeschlagen: {e}\n")
# Konsolen-Handler kompakt
ch = logging.StreamHandler(sys.stdout)
ch.setLevel(level)
ch.setFormatter(logging.Formatter(_CONSOLE_FORMAT))
logger.addHandler(ch)
return logger
# Globaler Root-Logger einmalig instanziiert
log = _setup_logger()
def get_logger(component: str) -> logging.Logger:
"""
Liefert einen Sub-Logger pro Komponente, z.B. 'trade', 'mt5', 'ai'.
Erscheint im Log dann als 'oil.trade'.
"""
return log.getChild(component)
def set_console_level(level: str | int):
"""Erlaubt Runtime-Änderung des Konsolen-Log-Levels."""
if isinstance(level, str):
level = getattr(logging, level.upper(), logging.INFO)
for h in log.handlers:
if isinstance(h, logging.StreamHandler) and not isinstance(
h, RotatingFileHandler):
h.setLevel(level)
+60
View File
@@ -0,0 +1,60 @@
"""
core/mailer.py — E-Mail via Microsoft Graph (client-credentials)
=================================================================
Sendet HTML-Mails über die Graph-App-Registrierung in `[graph]`
(tenant_id/client_id/client_secret). Absender = `[graph] sender` oder
Default mailagent@hocks.eu. Ersetzt den PowerShell-Umweg (send_daily_report.ps1)
durch reines Python — so kann die Engine den Tagesreport selbst verschicken.
"""
from __future__ import annotations
from core.logger import get_logger
log = get_logger("mail")
_TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"
_SENDMAIL = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail"
_DEFAULT_SENDER = "mailagent@hocks.eu"
def send_graph_mail(graph_cfg, subject: str, html: str, to_addr: str) -> bool:
"""True bei Erfolg. `to_addr` darf mehrere Adressen kommagetrennt enthalten.
Fail-safe: loggt + gibt False zurück, wirft nie."""
import requests
g = graph_cfg or {}
tenant = (g.get("tenant_id") or "").strip()
cid = (g.get("client_id") or "").strip()
secret = (g.get("client_secret") or "").strip()
sender = (g.get("sender") or _DEFAULT_SENDER).strip()
recipients = [{"emailAddress": {"address": a.strip()}}
for a in (to_addr or "").split(",") if a.strip()]
if not (tenant and cid and secret) or not recipients:
log.warning("Graph-Config/Empfänger unvollständig — keine E-Mail")
return False
try:
tok = requests.post(
_TOKEN_URL.format(tenant=tenant),
data={"client_id": cid, "client_secret": secret,
"scope": "https://graph.microsoft.com/.default",
"grant_type": "client_credentials"}, timeout=30)
tok.raise_for_status()
access = tok.json().get("access_token")
if not access:
log.warning("Graph: kein access_token"); return False
r = requests.post(
_SENDMAIL.format(sender=sender),
headers={"Authorization": "Bearer " + access},
json={"message": {
"subject": subject,
"body": {"contentType": "HTML", "content": html},
"toRecipients": recipients,
"from": {"emailAddress": {"address": sender}}},
"saveToSentItems": True}, timeout=30)
if r.status_code in (200, 202):
log.info(f"E-Mail an {to_addr}: {subject}")
return True
log.warning(f"Graph sendMail {r.status_code}: {r.text[:200]}")
return False
except Exception as e:
log.warning(f"send_graph_mail: {e}")
return False
+77
View File
@@ -0,0 +1,77 @@
"""
core/market_hours.py — Börsen-Session-Status (Frankfurt / US)
==============================================================
Liefert den aktuellen Session-Status für die Empfehlungs-Logik, das UI und
den KI-Agenten. Zeiten in **Berlin-Lokalzeit** (DST-sicher via zoneinfo):
DE (Frankfurt): 09:00 17:30
US (Wall St.): 15:00 22:00 (Overlap 15:0017:30 = höchste Liquidität)
Nutzen:
"just_opened": die ersten _CAUTION_MIN nach einem Open (Whipsaw-Vorsicht)
"active": welche Sessions gerade offen sind (Liquiditäts-Bonus)
"next_open": nächster Open + Minuten bis dahin (UI-Countdown)
"""
from __future__ import annotations
from datetime import datetime, time, timezone
try:
from zoneinfo import ZoneInfo
_BERLIN = ZoneInfo("Europe/Berlin")
except Exception:
_BERLIN = None
# Sessions in Berlin-Lokalzeit (Start, Ende)
_SESSIONS = {
"DE": (time(9, 0), time(17, 30)),
"US": (time(15, 0), time(22, 0)),
}
_CAUTION_MIN = 20 # erste N Min nach einem Open = volatil → Vorsicht
def _mins(t: time) -> int:
return t.hour * 60 + t.minute
def session_state(now: datetime | None = None) -> dict:
now = now or datetime.now(timezone.utc)
loc = now.astimezone(_BERLIN) if _BERLIN else now
is_weekend = loc.weekday() >= 5 # 5=Sa, 6=So
nowm = loc.hour * 60 + loc.minute
active, since, just_opened, next_open = [], {}, None, None
for name, (o, c) in _SESSIONS.items():
om, cm = _mins(o), _mins(c)
open_now = (not is_weekend) and (om <= nowm <= cm)
if open_now:
active.append(name)
if (not is_weekend) and nowm >= om:
since[name] = nowm - om
if open_now and (nowm - om) < _CAUTION_MIN:
just_opened = name
else:
since[name] = None
if (not is_weekend) and nowm < om:
d = om - nowm
if next_open is None or d < next_open["in_min"]:
next_open = {"name": name, "in_min": d}
if is_weekend:
phase = "Wochenende"
elif just_opened:
phase = f"{just_opened}-Open frisch (volatil)"
elif active:
phase = " + ".join(active) + "-Session offen"
elif next_open:
phase = f"vor {next_open['name']}-Open"
else:
phase = "außerhalb DE/US"
return {
"active": active,
"since_open_min": since,
"just_opened": just_opened,
"next_open": next_open,
"phase": phase,
"weekend": is_weekend,
}
+161
View File
@@ -0,0 +1,161 @@
"""
core/mt5_utils.py — Thread-sicherer MT5-Zugriff + Trading-Hilfsfunktionen
==========================================================================
Globaler Lock für die MetaTrader5-Lib (nicht thread-safe) sowie
alle kleinen Hilfsfunktionen die von TradeManager, TrailingManager
und MT5Data gemeinsam genutzt werden.
"""
from __future__ import annotations
import threading
from contextlib import contextmanager
import MetaTrader5 as mt5
from core.config import (
get_margin_buffer, SL_TF, SL_LOOKBACK, SL_WINDOW, SL_BUFFER_TICKS,
)
from core.logger import get_logger
log = get_logger("mt5")
# ── Globaler MT5-Lock ──────────────────────────
# Die MT5 Python-Lib ist NICHT thread-safe. Alle MT5-Calls laufen
# über diesen Lock. Mit Timeout, damit ein hängender order_send
# (Server antwortet nicht) nicht alle anderen Threads blockiert.
_mt5_lock = threading.Lock()
MT5_LOCK_TIMEOUT_S = 5
@contextmanager
def mt5_lock(timeout: float = MT5_LOCK_TIMEOUT_S):
"""
Context-Manager für den globalen MT5-Lock mit Timeout.
with mt5_lock() as got:
if not got:
return # Lock nicht erhalten → Tick überspringen
... MT5-Calls ...
"""
acquired = _mt5_lock.acquire(timeout=timeout)
if not acquired:
try:
yield False
finally:
pass
else:
try:
yield True
finally:
_mt5_lock.release()
# ── Tick / Symbol-Helpers ─────────────────────
def get_tick(sym: str):
t = mt5.symbol_info_tick(sym)
return t if (t and t.bid > 0 and t.ask > 0) else None
def get_filling(sym: str) -> int:
si = mt5.symbol_info(sym)
if si is None:
return mt5.ORDER_FILLING_IOC
for m in (mt5.ORDER_FILLING_FOK, mt5.ORDER_FILLING_IOC,
mt5.ORDER_FILLING_RETURN):
if si.filling_mode & m:
return m
return mt5.ORDER_FILLING_RETURN
def calc_lots(sym: str, price: float, otype: int) -> float:
si, acc = mt5.symbol_info(sym), mt5.account_info()
if not si or not acc:
return 0.0
mpl = mt5.order_calc_margin(otype, sym, 1.0, price)
if not mpl or mpl <= 0:
return 0.0
step = si.volume_step
lots = round(((acc.margin_free * get_margin_buffer() / mpl) // step) * step, 4)
# Margin-Guard: reicht die freie Margin nicht mal fürs Mindestvolumen, 0.0
# zurückgeben → Caller meldet sauber „Lot-Fehler" statt Broker-Reject mit
# kryptischem retcode (früher: max(volume_min, …) erzwang unbezahlbare Größe).
if lots < si.volume_min:
return 0.0
return min(lots, si.volume_max)
def calc_lots_risk(sym: str, price: float, otype: int,
sl_distance: float | None, risk_frac: float) -> float:
"""Risiko-basierte Lot-Größe: Verlust beim Initial-SL ≈ risk_frac × Equity.
Ersetzt das All-in-auf-freie-Margin von `calc_lots`. Die freie Margin bleibt
Obergrenze (Deckel), damit Mini-Konten nicht über die Margin hinaus ordern.
Gibt 0.0 zurück, wenn Daten fehlen → Caller fällt auf `calc_lots` zurück.
"""
si, acc = mt5.symbol_info(sym), mt5.account_info()
if not si or not acc or not sl_distance or sl_distance <= 0:
return 0.0
step = si.volume_step or 0.01
tick_size = si.trade_tick_size or si.point
tick_value = si.trade_tick_value
if not tick_size or not tick_value:
return 0.0
val_per_price = tick_value / tick_size # € je 1.0 Preis je 1 Lot
equity = acc.equity or acc.balance or 0.0
if equity <= 0:
return 0.0
raw = (equity * risk_frac) / (sl_distance * val_per_price)
lots = (raw // step) * step
# Margin-Deckel: nie mehr als die freie Margin (× Buffer) zulässt
mpl = mt5.order_calc_margin(otype, sym, 1.0, price)
if mpl and mpl > 0:
max_aff = ((acc.margin_free * get_margin_buffer() / mpl) // step) * step
lots = min(lots, max_aff)
lots = min(round(lots, 4), si.volume_max)
# Ergibt die Risiko-Rechnung WENIGER als das Mindestlot, NICHT auf volume_min
# aufrunden (das überschritte still das gewollte Risiko) → 0.0, Caller lehnt ab
# (Fix 2026-07-19; vorher max(volume_min, …)).
if lots < si.volume_min:
return 0.0
return lots
def atr_value(sym: str, tf: int = SL_TF, period: int = 14) -> float | None:
"""ATR (Wilder-vereinfacht: Mittel der True Ranges) auf `tf`."""
r = mt5.copy_rates_from_pos(sym, tf, 0, period + 1)
if r is None or len(r) < period + 1:
return None
trs = []
for i in range(1, len(r)):
h, l, pc = float(r[i]["high"]), float(r[i]["low"]), float(r[i - 1]["close"])
trs.append(max(h - l, abs(h - pc), abs(l - pc)))
return sum(trs) / len(trs) if trs else None
def pivot_low(sym: str, max_above: float):
r = mt5.copy_rates_from_pos(sym, SL_TF, 0, SL_LOOKBACK)
if r is None or len(r) < 2 * SL_WINDOW + 1:
return None
lows = [float(x["low"]) for x in r]
for i in range(len(lows) - SL_WINDOW - 1, SL_WINDOW - 1, -1):
c = lows[i]
if (c < max_above
and c < min(lows[i - SL_WINDOW:i])
and c < min(lows[i + 1:i + 1 + SL_WINDOW])):
return c
return None
def pivot_high(sym: str, min_below: float):
r = mt5.copy_rates_from_pos(sym, SL_TF, 0, SL_LOOKBACK)
if r is None or len(r) < 2 * SL_WINDOW + 1:
return None
highs = [float(x["high"]) for x in r]
for i in range(len(highs) - SL_WINDOW - 1, SL_WINDOW - 1, -1):
c = highs[i]
if (c > min_below
and c > max(highs[i - SL_WINDOW:i])
and c > max(highs[i + 1:i + 1 + SL_WINDOW])):
return c
return None
+297
View File
@@ -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.
# MonFr 00:0022:00, So ab 22:00. Broker-Zeiten können minimal abweichen.
_OIL_SESSIONS_UTC = {
0: [(79200, 86400)], # So 22:0024:00
1: [(0, 79200)], # Mo 00:0022:00
2: [(0, 79200)], # Di
3: [(0, 79200)], # Mi
4: [(0, 79200)], # Do
5: [(0, 79200)], # Fr 00:0022: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()
+842
View File
@@ -0,0 +1,842 @@
"""
core/news.py — News-Fetcher + Übersetzer
==========================================
Lädt RSS-Feeds zu WTI-Crude/Öl-News und übersetzt sie optional auf Deutsch.
• NewsTranslator — deep-translator (Google + MyMemory-Fallback)
• NewsFetcher — 4 RSS-Feeds, Sortierung nach Datum
Cache der Übersetzungen liegt in oil_widget_translations.json
(neben dem Hauptscript) und überlebt Neustarts.
"""
from __future__ import annotations
import json
import hashlib
import threading
import time
from pathlib import Path
from core.logger import get_logger
log_trans = get_logger("trans")
log_news = get_logger("news")
# Cache-Pfad neben dem Hauptscript
TRANSLATION_CACHE_FILE = Path(__file__).parent.parent / "oil_widget_translations.json"
TRANSLATION_CACHE_MAX = 1000 # FIFO-Trim ab 1200
TRANSLATION_TIMEOUT_S = 8
# ══════════════════════════════════════════════
# NEWS-ÜBERSETZUNG
# ══════════════════════════════════════════════
class NewsTranslator:
"""
Übersetzt englische Nachrichten-Titel via deep-translator (Google).
• Persistenter JSON-Cache (kein doppelter API-Call für gleiche Headlines)
• Background-Threading: UI bleibt responsiv
• Fallback-Provider (Google → MyMemory) falls primary fehlschlägt
• Komplett lokal abschaltbar via [translation] enabled = false
"""
def __init__(self, enabled: bool = True,
target: str = "de",
provider: str = "google",
cache_file: Path = TRANSLATION_CACHE_FILE):
self.enabled = enabled
self.target = target.strip().lower() or "de"
self.provider = provider.strip().lower() or "google"
self.cache_file = cache_file
self.cache = {}
self._cache_dirty = False
self._lock = threading.Lock()
self.error = None
self.api_ok = None
self._load_cache()
def _load_cache(self):
if not self.cache_file.exists():
return
try:
with open(self.cache_file, "r", encoding="utf-8") as f:
self.cache = json.load(f)
log_trans.info(f"Cache geladen: {len(self.cache)} Einträge")
except Exception as e:
log_trans.error(f"Cache-Lesefehler: {e}")
self.cache = {}
def _save_cache(self):
if not self._cache_dirty:
return
try:
if len(self.cache) > TRANSLATION_CACHE_MAX + 200:
items = list(self.cache.items())
self.cache = dict(items[-TRANSLATION_CACHE_MAX:])
with open(self.cache_file, "w", encoding="utf-8") as f:
json.dump(self.cache, f, ensure_ascii=False, indent=1)
self._cache_dirty = False
except Exception as e:
log_trans.error(f"Cache-Schreibfehler: {e}")
@staticmethod
def _hash(text: str) -> str:
return hashlib.sha1(text.encode("utf-8", errors="ignore")).hexdigest()[:16]
def _translate_one(self, text: str) -> str | None:
try:
from deep_translator import GoogleTranslator
except ImportError:
self.error = "pip install deep-translator"
return None
try:
if self.provider == "google":
return GoogleTranslator(source="auto", target=self.target).translate(text)
except Exception as e:
log_trans.warning(f"Google-Fehler: {e}")
try:
from deep_translator import MyMemoryTranslator
src_full = "en-GB"
tgt_full = f"{self.target}-{self.target.upper()}"
return MyMemoryTranslator(source=src_full, target=tgt_full).translate(text)
except Exception as e:
log_trans.warning(f"MyMemory-Fehler: {e}")
return None
def translate(self, text: str) -> str:
if not self.enabled or not text:
return text
h = self._hash(text)
with self._lock:
cached = self.cache.get(h)
if cached:
return cached
result = self._translate_one(text)
if result and result.strip() and result.lower() != text.lower():
with self._lock:
self.cache[h] = result
self._cache_dirty = True
self.api_ok = True
return result
if result is None:
self.api_ok = False
return text
def translate_batch(self, headlines: list) -> list:
if not self.enabled:
return headlines
out = []
for h in headlines:
new = dict(h)
orig = h.get("title", "")
translated = self.translate(orig)
if translated != orig:
new["title_original"] = orig
new["title"] = translated
out.append(new)
if self._cache_dirty:
self._save_cache()
return out
def shutdown(self):
self._save_cache()
# ══════════════════════════════════════════════
# NEWS-FETCHER
# ══════════════════════════════════════════════
class NewsFetcher:
"""
Holt aktuelle WTI-Crude-News sowie geopolitische / makroökonomische News
aus öffentlichen RSS-Feeds.
Energie-Feeds (WTI / Rohstoff-spezifisch):
OilPrice, EIA Press, Rigzone, ShaleMag, Offshore Energy,
Oil&Gas 360, Guardian Oil
Makro- / Geopolitik-Feeds (marktrelevant für WTI):
Al Jazeera, BBC Business, BBC World, MarketWatch, The Hill Energy,
DW World, FT Commodities, Middle East Eye, Hellenic Shipping News
"""
# ── Energie / Rohstoffe — WTI-fokussiert ─────────────────────────────
FEEDS_ENERGY = [
("OilPrice", "https://oilprice.com/rss/main"),
("EIA Press", "https://www.eia.gov/rss/press_rss.xml"),
("Rigzone", "https://www.rigzone.com/news/rss/rigzone_latest.aspx"),
("ShaleMag", "https://shalemag.com/feed/"),
("Offshore Energy", "https://www.offshore-energy.biz/feed/"),
("Oil&Gas 360", "https://www.oilandgas360.com/feed/"),
("Guardian Oil", "https://www.theguardian.com/business/oil/rss"),
]
# ── Makro / Geopolitik (marktrelevant für WTI) ───────────────────────
# Reuters-Feed wurde 2020 abgeschaltet — nicht mehr verwenden.
FEEDS_GEO = [
("Al Jazeera", "https://www.aljazeera.com/xml/rss/all.xml"),
("BBC Business", "https://feeds.bbci.co.uk/news/business/rss.xml"),
("BBC World", "https://feeds.bbci.co.uk/news/world/rss.xml"),
("MarketWatch", "https://feeds.marketwatch.com/marketwatch/marketpulse/"),
("The Hill", "https://thehill.com/policy/energy-environment/feed/"),
("DW World", "https://rss.dw.com/rdf/rss-en-world"),
("FT Commodities","https://www.ft.com/commodities?format=rss"),
("Middle East Eye","https://www.middleeasteye.net/rss"),
("Hellenic Ship.", "https://www.hellenicshippingnews.com/feed/"),
]
# Kombination beider Feed-Gruppen
FEEDS = FEEDS_ENERGY + FEEDS_GEO
# Schlüsselwörter zur Relevanz-Filterung (Geo-Feeds)
GEO_KEYWORDS = {
# Öl/Energie allgemein
"oil", "crude", "wti", "brent", "petroleum", "energy",
"opec", "iea", "eia", "barrel", "supply", "demand",
"refin", "pipeline", "tanker", "stockpile", "inventory", "stocks",
"spr", "gas", "fuel", "commodity", "commodities", "lng",
# Schifffahrt / Tankerrouten — direkt preisrelevant
"shipping", "vessel", "suez", "canal", "chokepoint",
"bab-el-mandeb", "vlcc", "freight", "cargo",
# WTI-spezifisch: US-Produktion & Infrastruktur
"cushing", "permian", "bakken", "eagle ford", "marcellus",
"keystone", "gulf of mexico", "nymex", "shale", "fracking",
"us oil", "us energy", "us crude", "us production",
"refinery capacity", "hurricane",
# Sanktionen / Geopolitik
"iran", "sanction", "embargo", "strait", "hormuz", "gulf",
# Akteure
"saudi", "russia", "moscow", "putin", "iraq",
"venezuela", "libya", "nigeria", "kuwait", "uae", "qatar",
"ukraine", "hamas", "houthi", "yemen", "israel", "hezbollah",
"lebanon", "syria", "china", "beijing", "iran",
# Ereignisse
"war", "attack", "ceasefire", "truce", "drone", "missile",
"embargo", "blockade", "strike", "shutdown", "outage",
"disruption", "force majeure", "summit", "deal", "agreement",
"tariff", "trade war", "recession", "inflation", "gdp",
}
# ── Vollständige Feed-Pools inkl. Alternativen (Reihenfolge = Priorität) ──
# validate_feeds() testet alle Feeds parallel und ersetzt ausgefallene
# primäre Feeds automatisch mit dem nächsten funktionierenden Pool-Eintrag.
_ENERGY_POOL = [
# Primär
("OilPrice", "https://oilprice.com/rss/main"),
("EIA Press", "https://www.eia.gov/rss/press_rss.xml"),
("Rigzone", "https://www.rigzone.com/news/rss/rigzone_latest.aspx"),
("ShaleMag", "https://shalemag.com/feed/"),
("Offshore Energy", "https://www.offshore-energy.biz/feed/"),
("Oil&Gas 360", "https://www.oilandgas360.com/feed/"),
("Guardian Oil", "https://www.theguardian.com/business/oil/rss"),
# Alternativen (automatischer Fallback)
("Energy Monitor", "https://www.energymonitor.ai/feed/"),
("Natural Gas Int.", "https://www.naturalgasintel.com/feed/"),
]
_GEO_POOL = [
# Primär
("Al Jazeera", "https://www.aljazeera.com/xml/rss/all.xml"),
("BBC Business", "https://feeds.bbci.co.uk/news/business/rss.xml"),
("BBC World", "https://feeds.bbci.co.uk/news/world/rss.xml"),
("MarketWatch", "https://feeds.marketwatch.com/marketwatch/marketpulse/"),
("The Hill", "https://thehill.com/policy/energy-environment/feed/"),
("DW World", "https://rss.dw.com/rdf/rss-en-world"),
("FT Commodities", "https://www.ft.com/commodities?format=rss"),
("Middle East Eye", "https://www.middleeasteye.net/rss"),
("Hellenic Ship.", "https://www.hellenicshippingnews.com/feed/"),
# Alternativen (automatischer Fallback)
("New Arab", "https://www.newarab.com/rss.xml"),
]
# WTI-Direktseite (HTML-Scraper, keine RSS)
OILPRICE_WTI_URL = "https://oilprice.com/futures/wti/"
# Globale Öl-Preistabelle (Breadth + WTI/Brent Spot)
OILPRICE_CHARTS_URL = "https://oilprice.com/oil-price-charts/"
# HTTP-Timeout pro Feed in Sekunden — verhindert dass langsame/tote
# Feeds den ganzen Fetch-Thread blockieren.
FETCH_TIMEOUT_S = 8
def __init__(self, translator: 'NewsTranslator | None' = None):
self.headlines = []
self.last_fetch = None
self.error = None
self.translator = translator
self.sentiment = {"score": 0.0, "n_bull": 0, "n_bear": 0, "samples": []}
self.price_data: dict | None = None # OilPrice Charts Breadth + Spot
# Pro-Feed Status: {source: {"ok": bool, "n": int, "msg": str}}
self.feed_status: dict = {}
# Validierte, aktive Feed-Listen (None = noch nicht validiert → Klassenvariable)
self._active_energy: list | None = None
self._active_geo: list | None = None
self._lock = threading.Lock()
def _is_geo_relevant(self, title: str) -> bool:
"""Gibt True zurück, wenn ein Geo-Feed-Titel marktrelevante Keywords enthält."""
low = title.lower()
return any(kw in low for kw in self.GEO_KEYWORDS)
# ══════════════════════════════════════════════
# FEED-VALIDIERUNG (einmalig beim Start)
# ══════════════════════════════════════════════
def _test_url(self, source: str, url: str) -> tuple[bool, int, str]:
"""Schneller Feed-Test ohne feed_status zu verändern. Gibt (ok, n_entries, msg) zurück."""
try:
import feedparser, requests
except ImportError:
return False, 0, "missing: feedparser/requests"
try:
resp = requests.get(
url,
timeout=(2, 6), # kurze Timeouts für schnelle Validierung
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 OilWidget/1.0",
"Accept": "application/rss+xml, application/xml, text/xml, */*",
},
)
if resp.status_code != 200:
return False, 0, f"HTTP {resp.status_code}"
feed = feedparser.parse(resp.content)
n = len(feed.entries)
if n == 0:
return False, 0, "0 Einträge"
return True, n, "ok"
except Exception as e:
return False, 0, str(e)[:60]
def validate_feeds(self) -> dict:
"""
Testet alle Feeds aus _ENERGY_POOL / _GEO_POOL parallel.
Ausgefallene Primär-Feeds werden automatisch durch den nächsten
funktionierenden Pool-Eintrag ersetzt.
Ergebnis wird in self._active_energy / self._active_geo gespeichert.
Aufruf: einmalig beim Start in einem Daemon-Thread.
"""
import concurrent.futures
all_feeds = list({u: (s, u) for s, u in
self._ENERGY_POOL + self._GEO_POOL}.values())
log_news.info(f"Feed-Validierung: teste {len(all_feeds)} Feeds …")
# Parallel testen
test_results: dict[str, tuple[bool, int, str]] = {} # url → (ok, n, msg)
with concurrent.futures.ThreadPoolExecutor(max_workers=12) as ex:
fut_map = {ex.submit(self._test_url, s, u): (s, u) for s, u in all_feeds}
try:
for fut in concurrent.futures.as_completed(fut_map, timeout=25):
src, url = fut_map[fut]
try:
ok, n, msg = fut.result()
except Exception as e:
ok, n, msg = False, 0, str(e)[:50]
test_results[url] = (ok, n, msg)
status = "OK" if ok else "FAIL"
log_news.debug(f" [{status}] {src}: {msg} ({n} Einträge)")
except concurrent.futures.TimeoutError:
log_news.warning("Feed-Validierung: Timeout nach 25 s")
def build_active(primary: list, pool: list) -> tuple[list, list]:
"""
Gibt (aktive_feeds, ersetzungen) zurück.
Primäre Feeds haben Vorrang; ausgefallene werden mit dem
nächsten noch nicht verwendeten Pool-Feed ersetzt.
"""
primary_urls = {u for _, u in primary}
alternatives = [(s, u) for s, u in pool if u not in primary_urls]
active = []
used_urls: set[str] = set()
replacements = []
for src, url in primary:
ok, n, msg = test_results.get(url, (False, 0, "nicht getestet"))
if ok and n > 0:
active.append((src, url))
used_urls.add(url)
else:
log_news.warning(f"Feed ausgefallen: {src} [{msg}] → suche Ersatz")
replaced = False
for alt_src, alt_url in alternatives:
if alt_url in used_urls:
continue
alt_ok, alt_n, alt_msg = test_results.get(alt_url, (False, 0, "nicht getestet"))
if alt_ok and alt_n > 0:
active.append((alt_src, alt_url))
used_urls.add(alt_url)
replacements.append((src, alt_src))
log_news.info(f" → Ersetzt: {src}{alt_src}")
replaced = True
break
if not replaced:
log_news.warning(f" → Kein Ersatz verfügbar für {src}")
return active, replacements
active_e, rep_e = build_active(self.FEEDS_ENERGY, self._ENERGY_POOL)
active_g, rep_g = build_active(self.FEEDS_GEO, self._GEO_POOL)
with self._lock:
self._active_energy = active_e
self._active_geo = active_g
total_ok = sum(1 for ok, _, _ in test_results.values() if ok)
total_fail = len(test_results) - total_ok
log_news.info(
f"Feed-Validierung abgeschlossen: {total_ok} OK / {total_fail} ausgefallen "
f"| Energie: {len(active_e)} aktiv ({len(rep_e)} ersetzt) "
f"| Geo: {len(active_g)} aktiv ({len(rep_g)} ersetzt)"
)
return {
"energy_active": active_e,
"geo_active": active_g,
"replacements": rep_e + rep_g,
"n_ok": total_ok,
"n_fail": total_fail,
}
def _fetch_oilprice_wti_scrape(self) -> list:
"""
Scrapet https://oilprice.com/futures/wti/ nach WTI-spezifischen Headlines.
Nutzt BeautifulSoup wenn verfügbar, sonst Regex-Fallback.
Gibt bis zu 8 Einträge im selben Format wie RSS-Feeds zurück.
"""
try:
import requests
except ImportError:
return []
try:
resp = requests.get(
self.OILPRICE_WTI_URL,
timeout=(3, self.FETCH_TIMEOUT_S),
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0 OilWidget/1.0",
"Accept": "text/html,application/xhtml+xml,*/*",
"Accept-Language": "en-US,en;q=0.9",
},
)
except Exception as e:
log_news.warning(f"OilPrice WTI scrape: {e}")
self.feed_status["OilPrice WTI"] = {"ok": False, "n": 0, "msg": str(e)[:60]}
return []
if resp.status_code != 200:
log_news.warning(f"OilPrice WTI: HTTP {resp.status_code}")
self.feed_status["OilPrice WTI"] = {
"ok": False, "n": 0, "msg": f"HTTP {resp.status_code}"}
return []
items = []
now = time.time()
seen = set()
# Artikel-Pfade auf OilPrice.com: /Energy/... und /Latest-Energy-News/...
_ARTICLE_PATHS = ("/Energy/", "/Latest-Energy-News/")
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.content, "html.parser")
for tag in soup.find_all("a", href=True):
href = tag.get("href", "")
title = tag.get_text(strip=True)
# Nur Artikel-Pfade, keine Navigation/Werbung
if not any(p in href for p in _ARTICLE_PATHS):
continue
if len(title) < 25 or title in seen:
continue
if not href.startswith("http"):
href = ("https://oilprice.com" + href
if href.startswith("/") else "")
if not href:
continue
seen.add(title)
items.append({
"title": title,
"link": href,
"source": "OilPrice WTI",
"ts": now,
"category": "energy",
})
if len(items) >= 8:
break
except ImportError:
# Regex-Fallback: kein beautifulsoup4 installiert
import re
pat = re.compile(
r'<a[^>]+href="(https?://oilprice\.com/'
r'(?:Energy|Latest-Energy-News)/[^"#?]{10,})"[^>]*>'
r'\s*([^<\n]{25,200})\s*</a>',
re.IGNORECASE | re.DOTALL,
)
for href, raw in pat.findall(resp.text):
title = re.sub(r'\s+', ' ', raw).strip()
if not title or title in seen:
continue
seen.add(title)
items.append({
"title": title,
"link": href,
"source": "OilPrice WTI",
"ts": now,
"category": "energy",
})
if len(items) >= 8:
break
if not items:
log_news.info("OilPrice WTI: BeautifulSoup nicht installiert "
"(pip install beautifulsoup4 lxml) — Regex-Fallback aktiv")
self.feed_status["OilPrice WTI"] = {
"ok": len(items) > 0,
"n": len(items),
"msg": "scrape ok" if items else "0 Headlines",
}
log_news.info(f"OilPrice WTI scrape: {len(items)} Headlines")
return items
def _fetch_oilprice_charts(self) -> dict | None:
"""
Scrapet https://oilprice.com/oil-price-charts/ und extrahiert:
• WTI Crude und Brent Crude: Preis, absolute Änderung, %Änderung
• Market Breadth aus Tabelle 'Futures & Indexes':
Anteil der Öl-Benchmarks mit positivem Tageschange
• breadth_signal: -1.0 (alle runter) … +1.0 (alle rauf)
Breadth > +0.5 → bullisher Marktkontext
Breadth < -0.5 → bärischer Marktkontext
"""
try:
import requests
except ImportError:
return None
try:
resp = requests.get(
self.OILPRICE_CHARTS_URL,
timeout=(3, self.FETCH_TIMEOUT_S),
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 Chrome/120.0 OilWidget/1.0",
"Accept": "text/html,application/xhtml+xml,*/*",
"Accept-Language": "en-US,en;q=0.9",
},
)
except Exception as e:
log_news.warning(f"OilPrice Charts fetch: {e}")
return None
if resp.status_code != 200:
log_news.warning(f"OilPrice Charts: HTTP {resp.status_code}")
return None
try:
from bs4 import BeautifulSoup
except ImportError:
log_news.warning("OilPrice Charts: BeautifulSoup fehlt (pip install beautifulsoup4)")
return None
try:
soup = BeautifulSoup(resp.content, "html.parser")
tables = soup.find_all("table", class_="oilprices__table")
if not tables:
log_news.warning("OilPrice Charts: Tabelle nicht gefunden")
return None
t0 = tables[0] # "Futures & Indexes"
n_up = n_down = 0
wti = brent = None
for row in t0.find_all("tr"):
cells = row.find_all(["th", "td"])
if len(cells) < 4:
continue
name_cell = cells[1]
price_cell = cells[2]
change_cell = cells[3]
pct_cell = cells[4] if len(cells) > 4 else None
name = name_cell.get_text(strip=True)
try:
price = float(price_cell.get_text(strip=True).replace(",", ""))
except ValueError:
continue
change_text = change_cell.get_text(strip=True)
change_classes = change_cell.get("class", [])
try:
change = float(change_text.replace(",", ""))
except ValueError:
change = 0.0
is_up = any("up" in c for c in change_classes)
is_down = any("down" in c for c in change_classes)
if is_up:
n_up += 1
elif is_down:
n_down += 1
if name in ("WTI Crude", "Brent Crude"):
pct = 0.0
if pct_cell:
raw_pct = pct_cell.get_text(strip=True).split("(")[0]
try:
pct = float(raw_pct.replace("%", "").replace(",", ""))
except ValueError:
pass
entry = {"price": price, "change": change, "pct": pct}
if name == "WTI Crude":
wti = entry
else:
brent = entry
n_total = n_up + n_down
breadth_pct = round(n_up / n_total * 100, 1) if n_total else 50.0
breadth_signal = round((n_up - n_down) / n_total, 3) if n_total else 0.0
result = {
"wti": wti,
"brent": brent,
"n_up": n_up,
"n_down": n_down,
"n_total": n_total,
"breadth_pct": breadth_pct, # % der Futures die gestiegen sind
"breadth_signal": breadth_signal, # -1..+1
}
wti_str = f"WTI={wti['price']:.2f} ({wti['pct']:+.2f}%)" if wti else "WTI=?"
brent_str = f"Brent={brent['price']:.2f} ({brent['pct']:+.2f}%)" if brent else "Brent=?"
log_news.info(
f"OilPrice Charts: {wti_str} {brent_str} "
f"Breadth {breadth_pct:.0f}% ({n_up}up/{n_down}dn) signal={breadth_signal:+.2f}"
)
return result
except Exception as e:
log_news.warning(f"OilPrice Charts parse: {e}", exc_info=True)
return None
def price_data_snapshot(self) -> dict | None:
"""Thread-safe Kopie der letzten Preisdaten (OilPrice Charts)."""
with self._lock:
return dict(self.price_data) if self.price_data else None
def _load_feed(self, source: str, url: str):
"""
Lädt eine einzelne RSS-URL mit hardem HTTP-Timeout.
Liefert die feedparser-Feed oder None bei Fehler.
Setzt feed_status[source] für spätere Diagnose.
"""
try:
import feedparser
import requests
except ImportError as e:
self.feed_status[source] = {"ok": False, "n": 0, "msg": f"missing: {e}"}
return None
try:
resp = requests.get(
url,
timeout=(3, self.FETCH_TIMEOUT_S),
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 OilWidget/1.0",
"Accept": "application/rss+xml, application/xml, text/xml, */*",
},
)
if resp.status_code != 200:
self.feed_status[source] = {
"ok": False, "n": 0,
"msg": f"HTTP {resp.status_code}",
}
log_news.warning(f"{source}: HTTP {resp.status_code}")
return None
feed = feedparser.parse(resp.content)
if feed.bozo and not feed.entries:
# bozo=1 mit Entries ist meist nur "namespace warning" → OK
msg = str(feed.bozo_exception)[:80] if hasattr(feed, "bozo_exception") else "parse error"
self.feed_status[source] = {"ok": False, "n": 0, "msg": msg}
log_news.warning(f"{source}: {msg}")
return None
n = len(feed.entries)
self.feed_status[source] = {"ok": True, "n": n, "msg": "ok"}
return feed
except Exception as e:
self.feed_status[source] = {"ok": False, "n": 0, "msg": str(e)[:80]}
log_news.warning(f"{source}: {e}")
return None
def fetch(self):
try:
import feedparser # nur für Verfügbarkeits-Check
except ImportError:
with self._lock:
self.error = "pip install feedparser requests"
return
# Per-Feed Status für diesen Run leeren
self.feed_status = {}
energy_items = []
geo_items = []
# ── OilPrice WTI-Direktseite (Scraper, höchste Priorität) ──
wti_items = self._fetch_oilprice_wti_scrape()
energy_items.extend(wti_items)
# ── OilPrice Charts — Preis-Breadth ──
charts_data = self._fetch_oilprice_charts()
# Validierte Listen verwenden (nach validate_feeds()), sonst Klassen-Defaults
with self._lock:
energy_feeds = self._active_energy if self._active_energy is not None else self.FEEDS_ENERGY
geo_feeds = self._active_geo if self._active_geo is not None else self.FEEDS_GEO
# ── Energie-Feeds: bis zu 5 Einträge je Feed, keine Filterung ──
for source, url in energy_feeds:
feed = self._load_feed(source, url)
if feed is None:
continue
for entry in feed.entries[:5]:
pub = entry.get("published_parsed") or time.gmtime()
energy_items.append({
"title": entry.get("title", "(no title)").strip(),
"link": entry.get("link", ""),
"source": source,
"ts": time.mktime(pub),
"category": "energy",
})
# ── Geo-Feeds: bis zu 3 Einträge je Feed, nur relevante Titel ──
for source, url in geo_feeds:
feed = self._load_feed(source, url)
if feed is None:
continue
count = 0
for entry in feed.entries:
if count >= 3:
break
title = entry.get("title", "").strip()
if not self._is_geo_relevant(title):
continue
pub = entry.get("published_parsed") or time.gmtime()
geo_items.append({
"title": title,
"link": entry.get("link", ""),
"source": source,
"ts": time.mktime(pub),
"category": "geo",
})
count += 1
# Diagnose-Zeilen ins Log
ok_count = sum(1 for s in self.feed_status.values() if s["ok"])
fail_count = len(self.feed_status) - ok_count
log_news.info(
f"Feed-Status: {ok_count} OK, {fail_count} fehlgeschlagen"
)
for source, st in self.feed_status.items():
if st["ok"]:
log_news.debug(f"{source}: {st['n']} Einträge")
else:
log_news.info(f"{source}: {st['msg']}")
# ── Zusammenführen: WTI-Scraper + RSS Energie + Geo ──
# WTI-Scraper-Items haben ts=now → sortieren immer an erste Stelle wenn aktuell
energy_items.sort(key=lambda x: x["ts"], reverse=True)
geo_items.sort(key=lambda x: x["ts"], reverse=True)
# WTI-Scraper bekommt bis zu 4 Slots im Energy-Bucket (von 7 gesamt)
wti_out = [i for i in energy_items if i.get("source") == "OilPrice WTI"][:4]
rss_energy = [i for i in energy_items if i.get("source") != "OilPrice WTI"][:3]
items = wti_out + rss_energy + geo_items[:4]
items.sort(key=lambda x: x["ts"], reverse=True)
items = items[:10]
log_news.info(
f"→ WTI-Scraper:{len(wti_out)} + Energie-RSS:{len(rss_energy)} + "
f"Geo:{len(geo_items[:4])} = {len(items)} Headlines"
)
if self.translator and self.translator.enabled:
try:
items = self.translator.translate_batch(items)
except Exception as e:
log_news.warning(f"Übersetzung fehlgeschlagen: {e}")
# Sentiment auf den (ggf. übersetzten) Headlines berechnen.
# calc_news_sentiment() greift bevorzugt auf title_original (englisch) zurück.
try:
from core.analysis import calc_news_sentiment
sentiment = calc_news_sentiment(items)
except Exception as e:
log_news.warning(f"Sentiment-Berechnung fehlgeschlagen: {e}")
sentiment = {"score": 0.0, "n_bull": 0, "n_bear": 0, "samples": []}
# ── Breadth-Signal in Sentiment einblenden (Gewicht 25%) ──────────────
# Breadth misst ob die Mehrheit aller Öl-Benchmarks steigt/fällt.
# Nur bei starkem Signal (|breadth| > 0.4) und ohne starke News-Gegenmeinung.
if charts_data:
bs = charts_data.get("breadth_signal", 0.0)
if abs(bs) > 0.4:
raw_score = sentiment["score"]
blended = round(raw_score * 0.75 + bs * 0.25, 3)
blended = max(-1.0, min(1.0, blended))
sentiment = dict(sentiment)
sentiment["score"] = blended
sentiment["breadth_signal"] = bs
sentiment["breadth_pct"] = charts_data.get("breadth_pct", 50.0)
log_news.info(
f"Breadth {bs:+.2f} → Sentiment {raw_score:+.2f}{blended:+.2f}"
)
with self._lock:
self.headlines = items
self.last_fetch = time.time()
self.error = None if items else "Keine News-Feeds erreichbar"
self.sentiment = sentiment
self.price_data = charts_data
log_news.info(f"News-Sentiment: {sentiment['score']:+.2f} "
f"(bull={sentiment['n_bull']}, bear={sentiment['n_bear']})")
def snapshot(self):
with self._lock:
return list(self.headlines), self.last_fetch, self.error
def sentiment_snapshot(self) -> dict:
"""Thread-safe Kopie des letzten Sentiment-Snapshots."""
with self._lock:
return dict(self.sentiment)
def storm_state(self) -> dict:
"""News-Sturm-Indikator (REINE ANZEIGE, kein Signal): Headline-Rate der
letzten Stunde vs. Basisrate des vorhandenen Feed-Fensters. Viel frische
Öl-/Geo-Schlagzeilen auf einmal = Ereignis läuft → Vorsicht (Vola).
normal · elevated (≥3 frisch & ≥2× Basis) · storm (≥5 frisch & ≥3× Basis)."""
with self._lock:
items = [h for h in self.headlines if h.get("ts")]
now = time.time()
if not items:
return {"level": "normal", "fresh": 0, "base_per_h": 0.0}
fresh = sum(1 for h in items if now - h["ts"] <= 3600)
# Basis-Fenster min. 6 h verankern — sonst bläht ein frischer Burst die
# Basisrate selbst auf und der Sturm erkennt sich nicht (Selbst-Normierung).
span_h = min(48.0, max(6.0, (now - min(h["ts"] for h in items)) / 3600))
base = len(items) / span_h # Ø Headlines/Stunde im Fenster
level = "normal"
if fresh >= 5 and fresh >= 3 * base:
level = "storm"
elif fresh >= 3 and fresh >= 2 * base:
level = "elevated"
return {"level": level, "fresh": fresh, "base_per_h": round(base, 1)}
+112
View File
@@ -0,0 +1,112 @@
"""
core/notify.py — Telegram-Benachrichtigungen
=============================================
Sendet Trade-Abschlüsse als Telegram-Nachricht.
Kein externes Package nötig — nur urllib aus der Standardbibliothek.
"""
from __future__ import annotations
import json
import threading
import urllib.request
from core.logger import get_logger
log_notify = get_logger("notify")
# Setup-Namen für lesbare Telegram-Nachricht
_SETUP_LABELS = {
"TREND_PULLBACK_LONG": "Pullback ▲",
"TREND_PULLBACK_LONG_AT_SUPPORT": "Pullback ▲ @ Support",
"TREND_PULLBACK_SHORT": "Pullback ▼",
"TREND_PULLBACK_SHORT_AT_RESISTANCE": "Pullback ▼ @ Resistance",
"TREND_CONTINUATION_LONG": "Continuation ▲",
"TREND_CONTINUATION_SHORT": "Continuation ▼",
"BREAKOUT_LONG": "Breakout ▲",
"BREAKOUT_SHORT": "Breakout ▼",
"MEAN_REVERT_LONG": "Mean-Revert ▲",
"MEAN_REVERT_SHORT": "Mean-Revert ▼",
"DEAD_CAT_BOUNCE_SHORT": "Dead Cat Bounce ▼",
}
_CLOSED_BY_LABEL = {
"sl": "SL ❌",
"tp": "TP ✅",
"manual": "Manuell 🖐",
"emergency": "Emergency 🚨",
"unknown": "Unbekannt ⚠️",
}
def send_telegram(message: str, token: str, chat_id: str) -> None:
"""Sendet eine Telegram-Nachricht asynchron (blockiert nicht)."""
def _send():
try:
url = f"https://api.telegram.org/bot{token}/sendMessage"
body = json.dumps({
"chat_id": chat_id,
"text": message,
"parse_mode": "HTML",
}).encode("utf-8")
req = urllib.request.Request(
url, data=body,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read())
if not result.get("ok"):
log_notify.warning(f"Telegram API Fehler: {result}")
else:
log_notify.debug("Telegram-Nachricht gesendet")
except Exception as e:
log_notify.warning(f"Telegram-Send fehlgeschlagen: {e}")
threading.Thread(target=_send, daemon=True).start()
def build_trade_open_message(
direction: str,
symbol: str | None = None,
lots: float | None = None,
entry_price: float | None = None,
setup: str | None = None,
entry_ts: int | None = None,
) -> str:
"""Formatiert die Telegram-Nachricht für einen Trade-Einstieg."""
import datetime
dir_str = "LONG" if (direction or "").upper() in ("BUY", "LONG") else "SHORT"
emoji = "\U0001f7e2" if dir_str == "LONG" else "\U0001f534"
setup_str = _SETUP_LABELS.get(setup or "", setup or "")
sym_str = f" · {symbol}" if symbol else ""
import time as _time
time_str = (_time.strftime("%d.%m %H:%M", _time.localtime(entry_ts))
if entry_ts else _time.strftime("%d.%m %H:%M"))
lines = [f"{emoji} <b>{dir_str}{sym_str}</b>"]
if entry_price:
lines.append(f"Einstieg: {entry_price:.5g}"
+ (f" · {lots} Lots" if lots else ""))
if setup:
lines.append(f"Setup: {setup_str}")
lines.append(f"Zeit: {time_str}")
return "\n".join(lines)
def build_trade_close_message(
direction: str,
pnl: float,
commission: float,
closed_by: str,
setup: str | None,
exit_ts: int,
symbol: str | None = None,
) -> str:
"""Formatiert die Telegram-Nachricht für einen Trade-Abschluss."""
import datetime
pnl_net = pnl + (commission or 0.0)
win = pnl_net >= 0
sign = "+" if win else ""
import time as _time
ts_str = (_time.strftime("%d.%m", _time.localtime(exit_ts)) if win
else _time.strftime("%H:%M", _time.localtime(exit_ts)))
sym_str = f"{symbol} " if symbol else ""
return f"{sym_str}{sign}{pnl_net:.2f} {ts_str}"
+247
View File
@@ -0,0 +1,247 @@
"""
core/structure.py — Marktstruktur-Erkennung (ANZEIGE, kein Signal)
==================================================================
Erkennt aus den M30-Bars die klassische Price-Action-Struktur und liefert sie
als Kontext fürs Dashboard:
• Swing-Folge HH / HL / LH / LL (Pivot-Hochs/-Tiefs, jeweils vs. Vorgänger)
• letzter BOS (Break of Structure: Richtung + gebrochenes Level)
• Regressionskanal (Richtung + Position des Kurses im Kanal 0..1)
• Gesamt-Struktur up / down / range
REINE ANZEIGE — wie TF-Ampel/Squeeze/Bounce: KEIN Trade-Trigger, KEIN Verdict-
Gewicht. Die handelbaren Varianten sind separat gemessen & verworfen:
BOS-Entry ≈ Momentum-Continuation (`backtest_momentum.py`, regime-abhängig),
Kanal-/Zonen-Bounce ≈ P(break)-Level-Bounce (6× belegt: Münzwurf am Extrem).
Deshalb malt dieses Modul KEINE Richtung/Prognose — es beschreibt nur den Ist-Zustand.
Thread-sicher: refresh_market(sym) holt die Bars unter mt5_lock (~30 s gedrosselt),
snapshot() liefert den letzten Stand ohne MT5-Call.
"""
from __future__ import annotations
import threading
import time
import MetaTrader5 as mt5
from core.mt5_utils import mt5_lock
from core.logger import get_logger
log = get_logger("structure")
_TF = mt5.TIMEFRAME_M30 # Struktur auf M30 (klare Swings, wie der Referenz-Chart)
_N_BARS = 220
_PIVOT_K = 3 # Swing-Pivot-Fenster (k Bars je Seite)
_REG_N = 60 # Regressionsfenster für den Kanal (~30 h auf M30)
_SLOPE_DEAD = 0.015 # |Steigung/Bar| < dead×ATR → Kanal "flat"
_MAX_SWINGS = 6 # so viele letzte Swings ausgeben
_REFRESH_S = 30.0 # Drossel (Struktur ändert sich langsam, spart Lock-Zeit)
def _atr(highs, lows, closes, p=14):
trs = []
for i in range(1, len(closes)):
trs.append(max(highs[i]-lows[i], abs(highs[i]-closes[i-1]), abs(lows[i]-closes[i-1])))
return (sum(trs[-p:]) / min(len(trs), p)) if trs else None
def _pivots(highs, lows, k):
"""Alternierende Swing-Punkte → Liste (index, price, kind) mit kind 'H'/'L'.
Swing-High bei i: höchster Bar im Fenster [i-k .. i+k] und lokales Maximum."""
n = len(highs)
raw = []
for i in range(k, n - k):
win_hi = max(highs[i-k:i+k+1]); win_lo = min(lows[i-k:i+k+1])
if highs[i] == win_hi and highs[i] > highs[i-1] and highs[i] >= highs[i+1]:
raw.append((i, highs[i], "H"))
elif lows[i] == win_lo and lows[i] < lows[i-1] and lows[i] <= lows[i+1]:
raw.append((i, lows[i], "L"))
# Alternierung erzwingen: zwei gleiche Typen in Folge → den extremeren behalten
out = []
for p in raw:
if out and out[-1][2] == p[2]:
if (p[2] == "H" and p[1] > out[-1][1]) or (p[2] == "L" and p[1] < out[-1][1]):
out[-1] = p
else:
out.append(p)
return out
def _classify(pivots):
"""Swing-Folge als HH/HL/LH/LL (vs. jeweils vorheriges High bzw. Low)."""
labels = []
last_h = last_l = None
for idx, price, kind in pivots:
if kind == "H":
lab = ("HH" if (last_h is not None and price > last_h)
else "LH" if last_h is not None else "H")
last_h = price
else:
lab = ("HL" if (last_l is not None and price > last_l)
else "LL" if last_l is not None else "L")
last_l = price
labels.append({"type": lab, "price": round(price, 3), "idx": idx})
return labels
def _trend_state(labels):
recent = [l["type"] for l in labels[-4:]]
ups = sum(1 for t in recent if t in ("HH", "HL"))
dns = sum(1 for t in recent if t in ("LH", "LL"))
if ups >= 3 and ups > dns:
return "up"
if dns >= 3 and dns > ups:
return "down"
return "range"
def _last_bos(labels, n_bars):
"""Letzter Break of Structure: jüngstes HH (bullisch, Vorlauf-Hoch gebrochen)
bzw. LL (bärisch). Level = das gebrochene vorige Extrem; bars_ago aus dem Index."""
prev_h = prev_l = None
bos = None
for l in labels:
if l["type"] in ("HH", "LH"):
if l["type"] == "HH" and prev_h is not None:
bos = {"dir": "up", "level": prev_h, "idx": l["idx"]}
prev_h = l["price"]
else:
if l["type"] == "LL" and prev_l is not None:
bos = {"dir": "down", "level": prev_l, "idx": l["idx"]}
prev_l = l["price"]
if bos:
bos["bars_ago"] = max(0, (n_bars - 1) - bos.pop("idx"))
bos["level"] = round(bos["level"], 3)
return bos
def _channel(closes, atr):
N = min(_REG_N, len(closes))
if N < 5:
return None
ys = closes[-N:]
mx = (N - 1) / 2.0
my = sum(ys) / N
sxx = sum((x - mx) ** 2 for x in range(N))
sxy = sum((x - mx) * (ys[x] - my) for x in range(N))
slope = sxy / sxx if sxx else 0.0
intercept = my - slope * mx
resid = [ys[x] - (slope * x + intercept) for x in range(N)]
up_off, lo_off = max(resid), min(resid)
last_x = N - 1
mid = slope * last_x + intercept
upper, lower = mid + up_off, mid + lo_off
width = upper - lower
pos = (ys[-1] - lower) / width if width > 0 else 0.5
if atr and abs(slope) < _SLOPE_DEAD * atr:
d = "flat"
else:
d = "up" if slope > 0 else "down"
return {"dir": d, "pos": round(max(0.0, min(1.0, pos)), 2),
"upper": round(upper, 3), "lower": round(lower, 3), "mid": round(mid, 3),
"slope_atr": round(slope / atr, 3) if atr else None}
def _channel_anchors(closes, times, atr):
"""Kanal als 2 Ankerpunkte je Linie (Fensterstart + letzter abgeschl. Bar) mit
BROKER-Zeiten — für die MQL5-Bridge (OBJ_TREND, nach rechts verlängert).
closes/times = abgeschlossene Bars (gleich lang). Gibt {t1,t2,dir,upper,mid,lower}."""
N = min(_REG_N, len(closes))
if N < 5 or len(times) < N:
return None
seg = closes[-N:]; tt = times[-N:]
mx = (N - 1) / 2.0; my = sum(seg) / N
sxx = sum((x - mx) ** 2 for x in range(N))
sxy = sum((x - mx) * (seg[x] - my) for x in range(N))
b = sxy / sxx if sxx else 0.0
a = my - b * mx
resid = [seg[x] - (a + b * x) for x in range(N)]
up_off, lo_off = max(resid), min(resid)
m1 = a; m2 = a + b * (N - 1)
d = "flat" if (atr and abs(b) < _SLOPE_DEAD * atr) else ("up" if b > 0 else "down")
return {"t1": int(tt[0]), "t2": int(tt[-1]), "dir": d,
"upper": [round(m1 + up_off, 3), round(m2 + up_off, 3)],
"mid": [round(m1, 3), round(m2, 3)],
"lower": [round(m1 + lo_off, 3), round(m2 + lo_off, 3)]}
def channel_series(closes, atr, k):
"""Regressionskanal (mid/upper/lower) als Arrays der LETZTEN k Bars fürs
Chart-Overlay — Regression über die letzten _REG_N ABGESCHLOSSENEN Bars,
linear über alle k Bars extrapoliert (volle Chart-Breite). closes = alle
Closes (letzter = offener Bar). Gibt {dir, mid[], upper[], lower[]} zurück
(jeweils Länge k, deckungsgleich mit den zurückgelieferten Bars) oder None."""
if k < 2 or len(closes) < 6:
return None
cc = closes[:-1] # nur abgeschlossene Bars (wie die Struktur)
N = min(_REG_N, len(cc))
if N < 5:
return None
seg = cc[-N:]
mx = (N - 1) / 2.0
my = sum(seg) / N
sxx = sum((x - mx) ** 2 for x in range(N))
sxy = sum((x - mx) * (seg[x] - my) for x in range(N))
b = sxy / sxx if sxx else 0.0
a = my - b * mx # Preis bei x=0 (Fensterstart)
resid = [seg[x] - (a + b * x) for x in range(N)]
up_off, lo_off = max(resid), min(resid)
x0 = len(cc) - N # cc-Index von x=0
base = len(closes) - k # closes-Index des ersten Ausgabe-Bars
mid, up, lo = [], [], []
for j in range(k):
x = (base + j) - x0 # x relativ zum Fensterstart (extrapoliert)
m = a + b * x
mid.append(round(m, 3)); up.append(round(m + up_off, 3)); lo.append(round(m + lo_off, 3))
d = "flat" if (atr and abs(b) < _SLOPE_DEAD * atr) else ("up" if b > 0 else "down")
return {"dir": d, "mid": mid, "upper": up, "lower": lo}
class MarketStructure:
def __init__(self):
self._snap: dict = {"trend": None, "swings": [], "last_swing": None,
"bos": None, "channel": None, "tf": "M30", "error": None}
self._last_refresh = 0.0
self._lock = threading.Lock()
def refresh_market(self, sym: str):
now = time.time()
if now - self._last_refresh < _REFRESH_S:
return
try:
with mt5_lock(timeout=2) as got:
if not got:
return
bars = mt5.copy_rates_from_pos(sym, _TF, 0, _N_BARS)
if bars is None or len(bars) < _REG_N + 5:
return
# letzte (offene) Kerze weglassen → nur abgeschlossene Struktur
highs = [float(b["high"]) for b in bars[:-1]]
lows = [float(b["low"]) for b in bars[:-1]]
closes = [float(b["close"]) for b in bars[:-1]]
times = [int(b["time"]) for b in bars[:-1]] # Broker-Zeit (MQL5-Anker)
n = len(closes)
atr = _atr(highs, lows, closes)
piv = _pivots(highs, lows, _PIVOT_K)
labels = _classify(piv)
snap = {
"trend": _trend_state(labels) if labels else "range",
"swings": [{"type": l["type"], "price": l["price"]}
for l in labels[-_MAX_SWINGS:]],
"last_swing": labels[-1]["type"] if labels else None,
"bos": _last_bos(labels, n),
"channel": _channel(closes, atr),
"channel_line": _channel_anchors(closes, times, atr),
"tf": "M30",
"error": None,
}
with self._lock:
self._snap = snap
self._last_refresh = now
except Exception as e:
with self._lock:
self._snap["error"] = str(e)[:120]
log.warning(f"MarketStructure.refresh: {e}")
def snapshot(self) -> dict:
with self._lock:
return dict(self._snap)
+709
View File
@@ -0,0 +1,709 @@
"""
core/trader.py — TradeManager
==============================
Verwaltet offene Positionen, sendet Market-Orders an MT5,
loggt Trades in die HistoryLogger-DB.
"""
from __future__ import annotations
import threading
import time
import MetaTrader5 as mt5
from core.config import (
DEVIATION, MAGIC,
SL_BUFFER_TICKS, INIT_SL_FALLBACK, INIT_TP_RR,
INIT_SL_MIN_ATR, INIT_SL_MAX_ATR, get_risk_per_trade,
)
from core.mt5_utils import (
mt5_lock, get_tick, get_filling, calc_lots, calc_lots_risk,
pivot_low, pivot_high, atr_value,
)
from core.logger import get_logger
log_trade = get_logger("trade")
log_hist = get_logger("hist")
# Klartext für die häufigsten MT5-Order-Retcodes (statt „retcode=10027")
_RETCODE_MSG = {
10004: "Requote — Preis hat sich bewegt, nochmal",
10006: "Order abgelehnt",
10013: "Ungültige Anfrage",
10014: "Ungültiges Volumen (Lots)",
10015: "Ungültiger Preis",
10016: "Ungültiger SL/TP",
10017: "Handel deaktiviert",
10018: "Markt geschlossen",
10019: "Nicht genug Geld / Margin",
10020: "Preis verändert — nochmal",
10021: "Kein Preis (Markt zu / kein Tick)",
10024: "Zu viele Anfragen — kurz warten",
10026: "Algo-Handel SERVERSEITIG aus (Broker)",
10027: "⚠ Algo-Trading im MT5-Terminal AUS — 'Algo Trading'-Button aktivieren!",
10030: "Ungültiger Füllmodus",
10031: "Keine Verbindung zum Handelsserver",
}
def _retcode_msg(res) -> str:
rc = res.retcode if res else None
return _RETCODE_MSG.get(rc, f"Order-Fehler (retcode={rc})")
class TradeManager:
def __init__(self):
self.ticket = self.order_type = None
self.entry_price = self.lots = self.pnl = self.cur_price = 0.0
self.sl = self.tp = self.margin = 0.0
self.symbol = None
self.last_error = ""
self._lock = threading.Lock()
self.history: 'HistoryLogger | None' = None
self._open_context: dict = {}
self._swap: float = 0.0
self._commission: float = 0.0
self._tick_size: float | None = None
self._tick_value: float | None = None
self._si_cache: object = None
self._si_cache_ts: float = 0.0
def _calc_sl_tp(self, sym, otype, entry_price):
si = mt5.symbol_info(sym)
if not si:
return 0.0, 0.0, "?"
buf = SL_BUFFER_TICKS * (si.trade_tick_size or si.point)
# Risiko-Deckel: SL-Distanz max. INIT_SL_MAX_ATR × ATR(M15).
# Pivot-SLs lagen teils ~80 Pips weg → Einzelverluste -30..-40 €
# bei Durchschnittsgewinnen von ~+4 €.
atr = atr_value(sym)
max_dist = (INIT_SL_MAX_ATR * atr) if atr else None
min_dist = (INIT_SL_MIN_ATR * atr) if atr else None
capped = floored = False
if otype == mt5.ORDER_TYPE_BUY:
piv = pivot_low(sym, entry_price)
sl = (piv - buf) if piv else round(entry_price * (1 - INIT_SL_FALLBACK), si.digits)
if max_dist and entry_price - sl > max_dist:
sl = entry_price - max_dist; capped = True
if min_dist and entry_price - sl < min_dist:
sl = entry_price - min_dist; floored = True
sl = round(sl, si.digits)
sl_dist = entry_price - sl
tp = round(entry_price + INIT_TP_RR * sl_dist, si.digits) if sl_dist > 0 else 0.0
else:
piv = pivot_high(sym, entry_price)
sl = (piv + buf) if piv else round(entry_price * (1 + INIT_SL_FALLBACK), si.digits)
if max_dist and sl - entry_price > max_dist:
sl = entry_price + max_dist; capped = True
if min_dist and sl - entry_price < min_dist:
sl = entry_price + min_dist; floored = True
sl = round(sl, si.digits)
sl_dist = sl - entry_price
tp = round(entry_price - INIT_TP_RR * sl_dist, si.digits) if sl_dist > 0 else 0.0
src_str = f"M15-Pivot {piv:.3f}" if piv else "Fallback 1.2%"
if capped:
src_str += f", gekappt auf {INIT_SL_MAX_ATR}xATR={max_dist:.3f}"
if floored:
src_str += f", auf min {INIT_SL_MIN_ATR}xATR={min_dist:.3f} aufgeweitet"
return sl, tp, src_str
def _send(self, sym, otype):
with mt5_lock(timeout=15) as got:
if not got:
self.last_error = "MT5 belegt — bitte gleich nochmal"
return None, 0.0
return self._send_locked(sym, otype)
def _send_locked(self, sym, otype):
tick = get_tick(sym)
if not tick:
self.last_error = "Kein Tick"; return None, 0.0
price = tick.ask if otype == mt5.ORDER_TYPE_BUY else tick.bid
# SL ZUERST bestimmen → daraus risiko-basierte Lot-Größe (Verlust beim
# Initial-SL ≈ risk_pct der Equity). Margin bleibt Obergrenze. Fallback auf
# margin-basiert, wenn risk_pct=0 oder Daten fehlen. Behebt die großen
# EUR-Verluste aus 90 %-Margin × 2×ATR-SL.
sl, _tp, sl_src = self._calc_sl_tp(sym, otype, price)
risk = get_risk_per_trade()
sl_dist = abs(price - sl) if sl else None
if risk > 0:
# Risiko-Modus: KEIN stiller Fallback auf Margin-Sizing (75 % wäre ein
# Vielfaches des gewollten Risikos). Klappt die Risiko-Rechnung nicht
# (Daten fehlen / unter Mindestlot), wird der Trade abgelehnt.
lots = calc_lots_risk(sym, price, otype, sl_dist, risk)
if lots <= 0:
self.last_error = ("Risiko-Sizing nicht möglich (unter Mindestlot "
"oder Daten fehlen) — Trade abgelehnt")
return None, 0.0
else:
lots = calc_lots(sym, price, otype) # margin-basiert (risk_pct=0)
if lots <= 0:
self.last_error = "Lot-Fehler"; return None, 0.0
req = {
"action": mt5.TRADE_ACTION_DEAL, "symbol": sym, "volume": float(lots),
"type": otype, "price": float(price), "deviation": DEVIATION,
"magic": MAGIC,
"comment": f"Widget-{'BUY' if otype == mt5.ORDER_TYPE_BUY else 'SELL'}",
"type_filling": get_filling(sym),
}
if sl:
req["sl"] = float(sl)
res = mt5.order_send(req)
for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_FOK):
if res and res.retcode != 10030:
break
req["type_filling"] = mode
res = mt5.order_send(req)
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
log_trade.info(
f"{'BUY' if otype == mt5.ORDER_TYPE_BUY else 'SELL'} "
f"{lots:.2f}L @ {price:.3f} T={res.order} SL={sl:.3f} ({sl_src})")
# tick_size/value/lots sofort cachen — sonst liefert live_pnl()
# bis zum ersten Positions-Tick (≤1 s) None und die P&L bleibt leer
si = mt5.symbol_info(sym)
with self._lock:
self.lots = float(lots); self._swap = 0.0; self._commission = 0.0
if si:
self._tick_size = si.trade_tick_size or self._tick_size or 0.001
self._tick_value = si.trade_tick_value or self._tick_value or 1.0
return res.order, float(price)
self.last_error = _retcode_msg(res)
return None, 0.0
def open_long(self, sym):
with self._lock:
if self.ticket:
return "Position bereits offen!"
t, e = self._send(sym, mt5.ORDER_TYPE_BUY)
if not t:
return self.last_error
with self._lock:
self.ticket = t; self.order_type = mt5.ORDER_TYPE_BUY
self.entry_price = e; self.symbol = sym
self._log_open(t, sym, "BUY", e)
return ""
def open_short(self, sym):
with self._lock:
if self.ticket:
return "Position bereits offen!"
t, e = self._send(sym, mt5.ORDER_TYPE_SELL)
if not t:
return self.last_error
with self._lock:
self.ticket = t; self.order_type = mt5.ORDER_TYPE_SELL
self.entry_price = e; self.symbol = sym
self._log_open(t, sym, "SELL", e)
return ""
def close(self, reason: str = "manual"):
with mt5_lock(timeout=15) as got:
if not got:
return "MT5 belegt — bitte gleich nochmal"
return self._close_locked(reason)
def _close_locked(self, reason: str = "manual"):
with self._lock:
ticket = self.ticket; sym = self.symbol; otype = self.order_type
if not ticket:
return "Keine offene Position."
positions = mt5.positions_get(ticket=ticket)
if not positions:
with self._lock:
self.ticket = None
return "Position bereits geschlossen."
pos = positions[0]
tick = get_tick(sym)
if not tick:
return "Kein Tick."
ct = mt5.ORDER_TYPE_SELL if otype == mt5.ORDER_TYPE_BUY else mt5.ORDER_TYPE_BUY
cp = tick.bid if otype == mt5.ORDER_TYPE_BUY else tick.ask
req = {
"action": mt5.TRADE_ACTION_DEAL, "symbol": sym, "volume": float(pos.volume),
"type": ct, "position": ticket, "price": float(cp), "deviation": DEVIATION,
"magic": MAGIC, "comment": "Widget-CLOSE",
"type_filling": get_filling(sym),
}
res = mt5.order_send(req)
for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC, mt5.ORDER_FILLING_FOK):
if res and res.retcode != 10030:
break
req["type_filling"] = mode
res = mt5.order_send(req)
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
log_trade.info(f"CLOSE T={ticket} @ {cp:.3f} ({reason})")
self._log_close(ticket, cp, pos.profit, reason)
with self._lock:
self.ticket = None; self.order_type = None
self.entry_price = 0.0; self.lots = 0.0
self.pnl = 0.0; self.cur_price = 0.0; self.sl = self.tp = self.margin = 0.0
self._swap = 0.0; self._tick_size = None; self._tick_value = None
return ""
return "Close: " + _retcode_msg(res)
def partial_close_position(self, pos, si, frac: float = 0.5,
reason: str = "partial"):
"""
Schließt `frac` des Volumens einer offenen Position (Teil-Exit / Runner).
CALLER MUSS den globalen mt5_lock bereits halten (wird vom Trailing
innerhalb von _do_modify aufgerufen).
Rückgabe: (geschlossenes_volumen, schlusskurs) bei Erfolg,
sonst (0.0, fehlertext).
Die realisierte Teil-PnL wird NICHT separat geloggt — sie steckt als
eigener Deal an derselben position_id und wird beim finalen Close über
_log_external_close in die Gesamt-PnL des Trades aufsummiert.
"""
sym = getattr(pos, "symbol", self.symbol)
step = si.volume_step or 0.01
vmin = si.volume_min or step
full = float(pos.volume)
vol_close = round(round((full * frac) / step) * step, 8)
# Beide Seiten müssen >= Mindestvolumen bleiben — sonst kein Teil-Exit
if vol_close < vmin or (full - vol_close) < vmin:
return 0.0, "Volumen zu klein zum Teilen"
tick = get_tick(sym)
if not tick:
return 0.0, "Kein Tick"
otype = pos.type
ct = mt5.ORDER_TYPE_SELL if otype == mt5.ORDER_TYPE_BUY else mt5.ORDER_TYPE_BUY
cp = tick.bid if otype == mt5.ORDER_TYPE_BUY else tick.ask
req = {
"action": mt5.TRADE_ACTION_DEAL, "symbol": sym,
"volume": float(vol_close), "type": ct, "position": pos.ticket,
"price": float(cp), "deviation": DEVIATION, "magic": MAGIC,
"comment": "Widget-PARTIAL", "type_filling": get_filling(sym),
}
res = mt5.order_send(req)
for mode in (mt5.ORDER_FILLING_RETURN, mt5.ORDER_FILLING_IOC,
mt5.ORDER_FILLING_FOK):
if res and res.retcode != 10030:
break
req["type_filling"] = mode
res = mt5.order_send(req)
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
log_trade.info(
f"TEIL-EXIT ({reason}): {vol_close:.2f}L von {full:.2f}L "
f"@ {cp:.3f} T={pos.ticket}")
with self._lock:
self.lots = max(full - vol_close, 0.0)
return vol_close, float(cp)
return 0.0, f"retcode={res.retcode if res else 'None'}"
def set_open_context(self, *, ai_sentiment=None, ai_confidence=None,
rec_signal=None, rec_score=None,
setup=None, regime=None, rsi=None, news_score=None):
self._open_context = {
"ai_sentiment": ai_sentiment, "ai_confidence": ai_confidence,
"rec_signal": rec_signal, "rec_score": rec_score,
"setup": setup, "regime": regime, "rsi": rsi, "news_score": news_score,
}
def _log_open(self, ticket: int, sym: str, direction: str, entry_price: float):
if not self.history:
return
ctx = dict(self._open_context)
def delayed_log():
time.sleep(0.5)
sl = tp = None
try:
# Eigener Thread → MT5-Call MUSS über den globalen Lock laufen
# (sonst Race gegen copy_rates/positions_get der anderen Loops).
with mt5_lock(timeout=5) as got:
positions = mt5.positions_get(ticket=ticket) if got else None
if positions:
sl = float(positions[0].sl) or None
tp = float(positions[0].tp) or None
except Exception as e:
log_hist.warning(f"SL/TP-Lookup: {e}")
try:
self.history.log_trade_open(
ticket=ticket, symbol=sym, direction=direction,
lots=float(self.lots) or 0.0, entry_price=entry_price,
sl_at_entry=sl, tp_at_entry=tp,
ai_sentiment=ctx.get("ai_sentiment"),
ai_confidence=ctx.get("ai_confidence"),
rec_signal=ctx.get("rec_signal"),
rec_score=ctx.get("rec_score"),
setup=ctx.get("setup"), regime=ctx.get("regime"),
rsi_at_entry=ctx.get("rsi"), news_score=ctx.get("news_score"),
)
except Exception as e:
log_hist.error(f"log_trade_open: {e}")
threading.Thread(target=delayed_log, daemon=True).start()
def _log_close(self, ticket: int, exit_price: float, pnl: float, closed_by: str):
if not self.history:
return
try:
self.history.log_trade_close(
ticket=ticket, exit_price=exit_price, pnl=pnl, closed_by=closed_by,
)
except Exception as e:
log_hist.error(f"log_trade_close: {e}")
def refresh(self, sym):
with mt5_lock() as got:
if not got:
return
self._refresh_locked(sym)
def _refresh_locked(self, sym):
with self._lock:
ticket = self.ticket
if ticket is None:
on_sym = mt5.positions_get(symbol=sym) or []
all_pos = on_sym if on_sym else (mt5.positions_get() or [])
if all_pos:
own = [p for p in all_pos if getattr(p, "magic", 0) == MAGIC]
pick = own[0] if own else all_pos[0]
pos_sym = getattr(pick, "symbol", sym)
with self._lock:
self.ticket = pick.ticket; self.order_type = pick.type
self.entry_price = pick.price_open; self.symbol = pos_sym
self.lots = pick.volume; self.pnl = pick.profit
self.sl = float(getattr(pick, "sl", 0.0) or 0.0)
self.tp = float(getattr(pick, "tp", 0.0) or 0.0)
# Adoptierter Trade ohne SL → Schutz-SL nachrüsten (Lock gehalten)
if not self.sl:
psl, _ptp, _ps = self._calc_sl_tp(pos_sym, pick.type,
float(pick.price_open))
if psl:
r = mt5.order_send({"action": mt5.TRADE_ACTION_SLTP,
"symbol": pos_sym,
"position": pick.ticket,
"sl": float(psl)})
if r and r.retcode == mt5.TRADE_RETCODE_DONE:
with self._lock:
self.sl = float(psl)
log_trade.info(
f"Schutz-SL für adoptierten Trade "
f"T={pick.ticket} @ {psl:.3f}")
else:
log_trade.warning(
f"Schutz-SL fehlgeschlagen T={pick.ticket} "
f"rc={r.retcode if r else 'None'}")
try: # gebundene Margin (eingesetzter Betrag)
_m = mt5.order_calc_margin(pick.type, pos_sym,
pick.volume, pick.price_open)
if _m:
with self._lock:
self.margin = float(_m)
except Exception:
pass
source = "magic-match" if own else "externer Trade adoptiert"
cross = " ⚠ ANDERES Symbol!" if pos_sym != sym else ""
log_trade.info(
f"Position erkannt: T={pick.ticket} {pos_sym} "
f"{'BUY' if pick.type == mt5.ORDER_TYPE_BUY else 'SELL'} "
f"{pick.volume}L @ {pick.price_open:.3f} ({source}){cross}")
if self.history:
direction = "BUY" if pick.type == mt5.ORDER_TYPE_BUY else "SELL"
self.history.log_trade_open(
ticket=int(pick.ticket),
symbol=pos_sym,
direction=direction,
lots=float(pick.volume),
entry_price=float(pick.price_open),
)
return
pos = mt5.positions_get(ticket=ticket)
if not pos:
with self._lock:
last_pnl = self.pnl
last_price = self.cur_price
last_commission = self._commission
log_trade.info(f"Position {ticket} extern geschlossen pnl≈{last_pnl:.2f} commission={last_commission:.2f}")
# MT5 braucht ~1-2s um den Close-Deal in die History zu schreiben.
# Async mit kurzem Delay aufrufen, damit history_deals_get den Deal findet
# und closed_by korrekt als "manual"/"sl"/"tp" gesetzt wird (nicht "unknown").
def _log_async(t=ticket, pnl=last_pnl, price=last_price, comm=last_commission):
time.sleep(2)
with mt5_lock(timeout=10) as _got:
if _got:
self._log_external_close(t, fallback_pnl=pnl,
fallback_price=price,
fallback_commission=comm)
threading.Thread(target=_log_async, daemon=True).start()
with self._lock:
self.ticket = None; self.order_type = None
self.entry_price = 0.0; self.pnl = 0.0; self.lots = 0.0
self.cur_price = 0.0; self.sl = self.tp = self.margin = 0.0
self._swap = 0.0; self._commission = 0.0
self._tick_size = None; self._tick_value = None
return
p = pos[0]
tick = get_tick(sym)
swap = float(getattr(p, "swap", 0.0) or 0.0)
commission = float(getattr(p, "commission", 0.0) or 0.0)
now = time.time()
if now - self._si_cache_ts > 5.0:
self._si_cache = mt5.symbol_info(sym)
self._si_cache_ts = now
si = self._si_cache
try: # gebundene Margin (eingesetzter Betrag)
_m = mt5.order_calc_margin(p.type, sym, p.volume, p.price_open)
margin = float(_m) if _m else 0.0
except Exception:
margin = 0.0
with self._lock:
self.lots = p.volume
self._swap = swap
self._commission = commission
self.pnl = p.profit + swap
self.sl = float(getattr(p, "sl", 0.0) or 0.0)
self.tp = float(getattr(p, "tp", 0.0) or 0.0)
self.margin = margin
self.cur_price = (tick.bid if p.type == mt5.ORDER_TYPE_BUY
else tick.ask) if tick else p.price_current
if si:
self._tick_size = si.trade_tick_size or self._tick_size or 0.001
self._tick_value = si.trade_tick_value or self._tick_value or 1.0
def live_pnl(self, bid: float, ask: float) -> float | None:
with self._lock:
if self.ticket is None:
return None
otype = self.order_type; ep = self.entry_price
lots = self.lots; ts = self._tick_size
tv = self._tick_value; swap = self._swap
if not ts or not tv:
return None
cur = bid if otype == mt5.ORDER_TYPE_BUY else ask
diff = (cur - ep) if otype == mt5.ORDER_TYPE_BUY else (ep - cur)
return diff / ts * tv * lots + swap
def _broker_offset_s(self) -> int:
"""
Broker-Serverzeit minus UTC in Sekunden, auf 30 min gerundet
(z.B. UTC+3 → 10800). MT5 liefert deal.time/tick.time in
Broker-Zeit, NICHT in UTC — ohne Korrektur landen Timestamps
3 h verschoben in der DB.
Außerhalb der Handelszeiten kann der letzte Tick alt sein →
Ergebnis wird auf plausiblen Bereich [-12h, +14h] geprüft,
sonst 0 (keine Korrektur).
"""
try:
sym = self.symbol
tick = mt5.symbol_info_tick(sym) if sym else None
if tick and tick.time:
off = round((tick.time - time.time()) / 1800) * 1800
if -12 * 3600 <= off <= 14 * 3600:
return int(off)
except Exception:
pass
return 0
def _log_external_close(self, ticket: int,
fallback_pnl: float | None = None,
fallback_price: float | None = None,
fallback_commission: float = 0.0,
lookback_hours: int = 24):
"""
Versucht den externen Close über MT5-Deal-History zu rekonstruieren.
Methode 1 (primär): history_deals_get(position=ticket) ohne Zeitrange.
Ruft intern HistoryDealsGetByPosition() auf — sucht in der
kompletten History und funktioniert auf den meisten Brokern.
Methode 2 (Fallback): Zeitfenster-Suche nach position_id == ticket.
Greift, wenn Methode 1 leer zurückkommt (seltener Broker-Bug).
Methode 3 (letzter Ausweg): letzter bekannter PnL aus Trader-State,
closed_by bleibt "unknown".
"""
if not self.history:
return
try:
# ── Methode 1: position-basierter Lookup (kein Zeitfenster) ──────
pos_deals = mt5.history_deals_get(position=ticket)
own = [d for d in (pos_deals or [])
if getattr(d, "position_id", None) == ticket]
# ── Methode 2: Zeitfenster + position_id-Filter ───────────────────
if not own:
# history_deals_get filtert nach BROKER-Zeit, nicht UTC —
# ohne Offset läge das Fensterende 3h vor Broker-jetzt und
# frisch geschlossene Deals fielen heraus.
now_b = int(time.time()) + self._broker_offset_s()
from_ts = now_b - lookback_hours * 3600
range_deals = mt5.history_deals_get(from_ts, now_b + 300)
own = [d for d in (range_deals or [])
if getattr(d, "position_id", None) == ticket]
if own:
log_hist.debug(f"T={ticket}: Methode-2 lieferte {len(own)} Deals")
else:
n1 = len(pos_deals) if pos_deals else 0
n2 = len(range_deals) if range_deals else 0
log_hist.debug(
f"T={ticket}: keine Deals mit position_id={ticket} "
f"(M1={n1} Deals, M2={n2} Deals — Broker setzt position_id nicht)")
if own:
deals_sorted = sorted(own, key=lambda d: getattr(d, "time", 0))
close_deal = next(
(d for d in reversed(deals_sorted)
if d.entry == mt5.DEAL_ENTRY_OUT), None)
if close_deal:
reason = getattr(close_deal, "reason", None)
closed_by = "unknown"
try:
if reason == mt5.DEAL_REASON_SL: closed_by = "sl"
elif reason == mt5.DEAL_REASON_TP: closed_by = "tp"
elif reason in (mt5.DEAL_REASON_CLIENT,
mt5.DEAL_REASON_EXPERT,
mt5.DEAL_REASON_MOBILE,
mt5.DEAL_REASON_WEB): closed_by = "manual"
except AttributeError:
pass
commission = sum(getattr(d, "commission", 0) for d in own)
total_profit = sum(
getattr(d, "profit", 0) + getattr(d, "swap", 0)
+ getattr(d, "commission", 0)
for d in own)
# deal.time ist Broker-Zeit (z.B. UTC+3) → in UTC umrechnen
raw_ts = int(getattr(close_deal, "time", 0) or 0)
exit_ts = (raw_ts - self._broker_offset_s()) if raw_ts \
else int(time.time())
self.history.log_trade_close(
ticket=ticket, exit_price=float(close_deal.price),
pnl=float(total_profit), closed_by=closed_by,
exit_ts=exit_ts, commission=float(commission))
log_hist.info(
f"Externer Close: T={ticket} {closed_by} @ "
f"{close_deal.price:.3f} pnl={total_profit:.2f}")
return
log_hist.warning(f"T={ticket}: kein OUT-Deal in {len(own)} Deals")
# ── Methode 3: Fallback — letzter bekannter PnL ───────────────────
if fallback_pnl is not None:
self.history.log_trade_close(
ticket=ticket,
exit_price=float(fallback_price or 0.0),
pnl=float(fallback_pnl),
closed_by="unknown",
exit_ts=int(time.time()),
commission=fallback_commission)
log_hist.warning(
f"Externer Close (Fallback-PnL): T={ticket} "
f"pnl≈{fallback_pnl:.2f} commission={fallback_commission:.2f} "
f"price≈{fallback_price or 0:.3f}")
else:
log_hist.warning(
f"T={ticket}: keine Deal-Daten, kein Fallback-PnL — "
f"wird bei Reconcile als 'unknown' eingetragen")
except Exception as e:
log_hist.error(f"_log_external_close: {e}")
def reconcile_open_trades(self, lookback_hours: int = 168):
if not self.history:
return
open_trades = self.history.open_trades()
if not open_trades:
log_hist.info("Reconcile: keine offenen Trades in DB")
return
log_hist.info(f"Reconcile: prüfe {len(open_trades)} offene DB-Einträge …")
n_closed = n_orphaned = 0
cutoff_ts = int(time.time()) - lookback_hours * 3600
for trade in open_trades:
ticket = trade["ticket"]
try:
if mt5.positions_get(ticket=ticket):
continue
except Exception:
pass
self._log_external_close(ticket, lookback_hours=lookback_hours)
try:
still_open_ids = {t["ticket"] for t in self.history.open_trades()}
if ticket not in still_open_ids:
n_closed += 1
else:
# Position in MT5 weg, aber kein Deal gefunden →
# sofort als 'unknown' markieren (kein Age-Cutoff nötig,
# da MT5-Abwesenheit bereits bestätigt wurde).
self.history.log_trade_close(
ticket=ticket, exit_price=0.0,
pnl=0.0, closed_by="unknown",
exit_ts=int(time.time()))
n_orphaned += 1
log_hist.warning(
f"Reconcile: T={ticket} nicht in MT5 + keine Deals "
f"→ als 'unknown' markiert")
except Exception as e:
log_hist.error(f"Reconcile-Check T={ticket}: {e}")
log_hist.info(f"Reconcile fertig: {n_closed} nachgetragen, "
f"{n_orphaned} als 'unknown' markiert")
def modify_sltp(self, sl=None, tp=None):
"""Manuelles Setzen von SL/TP der offenen Position (TRADE_ACTION_SLTP).
None/leer = jeweiligen Broker-Wert beibehalten; 0 = entfernen. Prüft
Seite/Mindestabstand vorab (freundlichere Meldung als der Broker-Retcode).
Gibt Fehlertext zurück oder None bei Erfolg."""
with mt5_lock(timeout=5) as got:
if not got:
return "MT5 belegt"
if not self.ticket:
return "keine Position"
positions = mt5.positions_get(ticket=self.ticket)
if not positions:
return "keine Position"
pos = positions[0]; sym = pos.symbol
si = mt5.symbol_info(sym); tick = mt5.symbol_info_tick(sym)
if not si or not tick:
return "kein Symbol/Tick"
is_long = pos.type == mt5.ORDER_TYPE_BUY
cur = tick.bid if is_long else tick.ask
spread = getattr(si, "spread", 0) or 0
min_dist = max((si.trade_stops_level + spread + 5) * si.point, 0.01)
def _val(x, keep):
if x in (None, ""):
return float(keep or 0.0)
return float(x)
new_sl = _val(sl, pos.sl); new_tp = _val(tp, pos.tp)
if new_sl:
if is_long and new_sl >= cur - min_dist:
return f"SL muss < {cur - min_dist:.3f} liegen (unter Kurs)"
if not is_long and new_sl <= cur + min_dist:
return f"SL muss > {cur + min_dist:.3f} liegen (über Kurs)"
if new_tp:
if is_long and new_tp <= cur + min_dist:
return f"TP muss > {cur + min_dist:.3f} liegen (über Kurs)"
if not is_long and new_tp >= cur - min_dist:
return f"TP muss < {cur - min_dist:.3f} liegen (unter Kurs)"
res = mt5.order_send({
"action": mt5.TRADE_ACTION_SLTP,
"symbol": sym,
"position": pos.ticket,
"sl": round(new_sl, si.digits),
"tp": round(new_tp, si.digits),
})
if not res or res.retcode != mt5.TRADE_RETCODE_DONE:
return f"Broker lehnte ab (rc={getattr(res, 'retcode', '?')}: " \
f"{getattr(res, 'comment', '?')})"
with self._lock:
self.sl = round(new_sl, si.digits)
self.tp = round(new_tp, si.digits)
log.info(f"Manuelles SLTP: SL={self.sl} · TP={self.tp} (Ticket {pos.ticket})")
return None
def snapshot(self):
with self._lock:
return dict(
ticket=self.ticket, order_type=self.order_type,
entry_price=self.entry_price, lots=self.lots,
pnl=self.pnl, cur_price=self.cur_price,
sl=self.sl, tp=self.tp, margin=self.margin,
)
+717
View File
@@ -0,0 +1,717 @@
"""
core/trailing.py — TrailingManager (tick-basiert)
===================================================
High-Water-Mark wird bei jedem Preis-Tick aktualisiert (kein MT5-Call).
ATR aus Kerzenschlüssen — Timeframe wird automatisch gewählt:
starker Trend (Winkel-Abstand > 45°) → H1 (große Moves, breiter Puffer)
moderater Trend (20°–45°) → M30 (Mittelweg)
Seitwärts (<20°) → M15 (normale Range)
Gecacht, refresht nur bei neuer Kerze des jeweiligen Timeframes.
SL/TP-Modifikation via MT5 nur wenn Cooldown abgelaufen + Schwellwert überschritten.
SL drei Phasen (Phasen-Ratsche: nur vorwärts Init→Trail→Lock, nie zurück):
1. Profit < 0.3 × ATR → Initial-SL setzen (falls noch keiner), dann halten
2. 0.3 ≤ Profit < 3.5 → SL = HW ∓ mult×ATR; Breakeven-Floor (Entry) erst
ab Profit ≥ 1.0×ATR — sonst stoppt jeder Rückläufer
zum Entry mit ±0 aus
3. Profit ≥ 3.5 × ATR → Engeres Trailing (mult × 0.6, min 1.2×) zum Gewinn-Lock-In
TP (Forward-Ziel — zieht mit High-Water nach vorne mit):
Phase 1 (Init): TP = Entry ± 3.5×ATR (fester Forward-Puffer, kein Trailing)
Phase 2 (Trail): TP = HW ∓ 1.5×ATR (Trailing hinter HW — breit genug für Retrace)
Phase 3 (Lock): TP = HW ∓ 0.8×ATR (engeres Lock-in bei tiefem Profit)
TP wird in Init/Trail nie zurückbewegt (nur nach oben LONG bzw. unten SHORT);
in Phase Lock darf er näher an den Kurs rücken (Gewinnsicherung).
Dynamischer Multiplikator (Trendwinkel):
starker Trend (>45° Abstand von 90°) → 2.5× (moderat — Trend braucht Raum)
moderater Trend (20°–45° Abstand) → 2.0× (enger)
Seitwärts (<20° Abstand) → 3.0× (weiter, weil kein klarer Trend)
Stabilität: ATR-Timeframe UND Multiplikator werden beim Aktivieren eingefroren
und gelten für den gesamten Trade. Vorher wechselte die TF-Automatik mitten im
Trade (M30→H1: ATR 0.41→1.08) → Phase fiel von Trail auf Init zurück und der
SL wurde nie wieder bewegt. Manueller TF-Override im UI greift weiterhin sofort.
ATR-Floor: max(gemessener ATR, 0.25) — verhindert zu enges Trailing in ruhigen Märkten.
Schwellwert: max(8 Punkte, 4 % des ATR) — skaliert mit Marktvolatilität.
"""
from __future__ import annotations
import threading
import time
import MetaTrader5 as mt5
from core.mt5_utils import mt5_lock
from core.logger import get_logger
log = get_logger("trail")
_ATR_PERIOD = 14
_ATR_MIN = 0.06 # Untergrenze ATR. 0,12→0,06 gesenkt (gemessen,
# `backtest_atrfloor.py`, 2 Halbjahre): reale Vola fiel
# unter den alten Floor (H1: 81 % der Signale < 0,12 →
# Floor band dauernd, Exit-Distanzen künstlich ~1,31,6×
# breit). 0,06 in BEIDEN Hälften besser (H1 153→−27 Pts,
# H2 +512→+554); ganz ohne Floor nur marginal besser →
# 0,06 als Schutz gegen Absurd-ATR (Dead-Hours) behalten.
_TRAIL_START_ATR = 0.3 # Phase 1→2: ab diesem Profit startet HW-Trailing
_BREAKEVEN_ATR = 1.3 # Entry-Floor (Breakeven) ab diesem Profit. 0,6 war zu
# eng: 21 % der Trades wurden auf Breakeven gescratcht
# (backtest_exit.py be). 1,3 = gemessenes Plateau-Optimum:
# Scratch 5 %, Ø-R +15 %, ohne Tail-Risiko (SL-gedeckelt).
_PHASE4_ATR = 3.5 # Phase 2→3: ab hier engeres Trailing zum Lock-In
_PHASE4_MULT_SCALE = 0.6 # Multiplikator-Faktor in Phase 3
_PHASE4_MULT_MIN = 1.2 # Untergrenze Mult in Phase 3
_PARTIAL_TP_ATR = 1.5 # Teil-Exit: ab diesem Profit 1× die Hälfte sichern
_PARTIAL_TP_FRAC = 0.0 # Anteil beim Teil-Exit. 0 = AUS → kompletter Trade
# läuft bis TP/SL (User-Vorgabe: am TP voll schließen)
_TIMESTOP_MIN = 120 # Time-Stop (gemessen, `backtest_timestop.py`): Trade
# hängt nach 2 h noch in Phase "Init" (HW-Profit nie
# ≥0,3×ATR = Whipsaw-Opfer) → schließen. Verbessert
# BEIDE History-Hälften (H1 +98→+162, H2 +2191→+2298 R),
# Worst unverändert; kurze N (30/60 min) = Rauschen.
# 0 = aus.
_ADVERSE_EXIT_ATR = 0.0 # Früh-Ausstieg: läuft der Trade ≥ diese ATR-Distanz
# GEGEN den Einstieg, sofort schließen. 0 = AUS.
# Abgeschaltet: kappte trend-konforme Trades schon auf
# normalen 1×ATR-Bounces, bevor der Broker-SL (1.21.5×
# ATR) Raum gab. Schutz läuft jetzt allein über den SL.
_TP_TRAIL_ATR = 1.5 # Phase 2 (Trail): TP = HW ∓ 1.5×ATR (mehr Raum für Retrace)
_TP_LOCK_ATR = 0.8 # Phase 3 (Lock): TP = HW ∓ 0.8×ATR (Lock-in, weniger eng)
_TP_INIT_ATR = 3.5 # Phase 1 (Init): TP = Entry ± 3.5×ATR (weiter Forward-Puffer)
_MODIFY_COOLDOWN_S = 8 # normaler Abstand zwischen zwei Modifikationen
_FAST_COOLDOWN_S = 1 # Cooldown bei Ausbruch (HW bewegt sich schnell)
_BREAKOUT_ATR_FACTOR = 0.5 # HW-Bewegung > 0.5×ATR seit letztem Modify → Ausbruch erkannt
_ERROR_COOLDOWN_S = 30 # Pause nach fehlgeschlagenem order_send
_ACTIVATE_DELAY_S = 2 # Wartezeit nach Aktivierung bis erste Modifikation
_WARMUP_TICKS = 3 # erste N Ticks: kein Modify
_SL_THRESHOLD_PTS = 8 # SL-Mindestverbesserung in Punkten
_SL_THRESHOLD_ATR = 0.04 # SL-Mindestverbesserung als ATR-Anteil (max der beiden)
# Fallback-ATR-TF-Wahl nach Trendstärke (nur wenn kein Override gesetzt ist —
# normalerweise koppelt das Widget den TF an die Wellen-/Agent-TF).
_TF_STRONG = mt5.TIMEFRAME_H1 # starker Trend → H1
_TF_MODERATE = mt5.TIMEFRAME_M30 # moderater Trend → M30
_TF_RANGE = mt5.TIMEFRAME_M15 # Seitwärts → M15
_TF_LABELS = {
mt5.TIMEFRAME_M1: "M1",
mt5.TIMEFRAME_M5: "M5",
mt5.TIMEFRAME_M15: "M15",
mt5.TIMEFRAME_M30: "M30",
mt5.TIMEFRAME_H1: "H1",
}
# Trail-Multiplikator je Timeframe: niedrige TF (Scalp) → enger Stop,
# hohe TF (Trend) → mehr Puffer. Ersetzt die alte 2.54.5-Logik.
_MULT_BY_TF = {
mt5.TIMEFRAME_M1: 1.5,
mt5.TIMEFRAME_M5: 1.5,
mt5.TIMEFRAME_M15: 2.0,
mt5.TIMEFRAME_M30: 2.5,
mt5.TIMEFRAME_H1: 3.0,
}
_MULT_BREAKOUT_ADD = 0.5 # Breakouts brauchen etwas mehr Luft (gedeckelt)
_PHASE_RANK = {"Init": 0, "Trail": 1, "Lock": 2}
class TrailingManager:
"""Tick-basiertes Trailing-Stop-System mit M15-ATR als Abstandsmaß."""
def __init__(self, trader):
self.trader = trader
self.enabled = False
self._atr: float | None = None # aktiver ATR (vom gewählten TF)
self._atr_tf: int = _TF_RANGE # aktuell genutzter Timeframe
self._atr_tf_override: int | None = None # None = Auto
self._atr_cache: dict = {} # {tf: atr_value}
self._atr_bar_times: dict = {} # {tf: last_bar_time}
self._high_water: float | None = None
self._last_sl: float | None = None
self._last_tp: float | None = None
self._last_modify_ts: float = 0.0
self._last_idle_atr_ts: float = 0.0 # ATR-Refresh wenn Trailing aus
self._ticks: int = 0
self._sr_data: dict | None = None
self._trend_angle: float | None = None
self._phase: str = ""
self._hw_at_last_modify: float | None = None # HW-Stand beim letzten Modify
self._setup: str | None = None # aktives Setup (für Mult-Wahl)
self._active_tf: int | None = None # beim Aktivieren eingefrorener ATR-TF
self._trade_mult: float | None = None # beim Aktivieren eingefrorener Multiplikator
self._partial_done: bool = False # Teil-Exit pro Trade nur einmal
self._notify = None # optionaler Callback (Telegram)
self._no_close_until: float = 0.0 # Startup-Schonfrist (von der Engine gesetzt):
# bis dahin kein Time-Stop-Close nach Neustart
self._lock = threading.Lock()
def set_notify(self, fn):
"""Callback fn(event, volume, price, profit) für Teil-Exit-Meldungen."""
self._notify = fn
def set_no_close_until(self, ts: float):
"""Startup-Schonfrist: bis zu diesem Zeitstempel wird KEIN Time-Stop-Close
ausgelöst (die Engine setzt das beim Start — verhindert, dass ein nach
Neustart adoptierter Alt-Trade sofort per Time-Stop geschlossen wird)."""
self._no_close_until = ts or 0.0
# ── Setter von außen ──────────────────────────────────────────────────────
def set_atr_tf_override(self, tf: int | None):
"""Setzt den ATR-Timeframe (vom Widget an die Wellen-/Agent-TF gekoppelt).
None = Fallback auf Trendstärke-Wahl. Ein laufender Trade behält seinen
beim Aktivieren eingefrorenen TF — die neue Wahl greift erst beim nächsten
Trade (verhindert ATR-Sprünge/Phasen-Rückfall mitten im Trade)."""
with self._lock:
self._atr_tf_override = tf
label = _TF_LABELS.get(tf, "?") if tf is not None else "Auto"
log.info(f"ATR-TF gesetzt: {label}")
def set_sr(self, sr_data: dict | None):
with self._lock:
self._sr_data = sr_data
def set_trend_angle(self, angle: float):
with self._lock:
self._trend_angle = angle
def set_setup(self, setup: str | None):
"""Aktuelles Setup setzen — beeinflusst SL-Multiplikator."""
with self._lock:
self._setup = setup
# ── ATR-Multiplikator (Timeframe-basiert) ─────────────────────────────────
def _trail_mult(self) -> float:
"""Muss unter self._lock aufgerufen werden. Stop-Distanz richtet sich
nach dem Trade-Timeframe: Scalp-TF (M1/M5) → eng, Trend-TF (H1) → weit.
So passt der Stop zum tatsächlichen Trade-Horizont (vorher pauschal
2.54.5, viel zu weit für M1/M5-Scalps)."""
tf = self._active_tf or self._atr_tf
base = _MULT_BY_TF.get(tf, 2.0)
if self._setup and "BREAKOUT" in self._setup:
base += _MULT_BREAKOUT_ADD
return base
# ── Automatische ATR-Timeframe-Wahl ──────────────────────────────────────
def _choose_atr_tf(self) -> int:
"""Wählt ATR-Timeframe: Override wenn gesetzt, sonst automatisch nach Trendstärke."""
with self._lock:
override = self._atr_tf_override
angle = self._trend_angle
if override is not None:
return override
if angle is None:
return _TF_RANGE
strength = abs(angle - 90.0)
if strength > 45:
return _TF_STRONG # starker Trend → H1
elif strength > 20:
return _TF_MODERATE # moderater Trend → M30
return _TF_RANGE # Seitwärts → M15
# ── ATR (gecacht, refresht nur bei neuer Kerze des gewählten TF) ─────────
def _refresh_atr(self, sym: str, tf: int) -> bool:
"""Muss unter aktivem MT5-Lock aufgerufen werden."""
bars = mt5.copy_rates_from_pos(sym, tf, 0, _ATR_PERIOD + 2)
if bars is None or len(bars) < _ATR_PERIOD + 1:
# Fallback: gecachten Wert für diesen TF weiterverwenden
cached = self._atr_cache.get(tf)
if cached is not None:
self._atr = cached
return True
return self._atr is not None
last_bar_time = int(bars[-2]["time"]) # -2 = letzte abgeschlossene Kerze
if last_bar_time == self._atr_bar_times.get(tf) and self._atr_cache.get(tf) is not None:
self._atr = self._atr_cache[tf]
return True
trs = []
for i in range(1, len(bars) - 1): # aktuelle (offene) Kerze weglassen
h = float(bars[i]["high"])
l = float(bars[i]["low"])
c = float(bars[i - 1]["close"])
trs.append(max(h - l, abs(h - c), abs(l - c)))
if trs:
atr_val = sum(trs[-_ATR_PERIOD:]) / min(len(trs), _ATR_PERIOD)
self._atr_cache[tf] = atr_val
self._atr_bar_times[tf] = last_bar_time
prev_tf = self._atr_tf
self._atr = atr_val
self._atr_tf = tf
effective = max(atr_val, _ATR_MIN)
tf_label = _TF_LABELS.get(tf, str(tf))
floor_note = f" (Floor, raw={atr_val:.4f})" if atr_val < _ATR_MIN else ""
tf_note = (f" [TF: {_TF_LABELS.get(prev_tf, '?')}{tf_label}]"
if prev_tf != tf else "")
log.info(f"ATR(14/{tf_label}) = {effective:.4f}{floor_note}{tf_note}")
return self._atr is not None
# ── Haupt-Tick (aufgerufen nach jedem Preis-Tick) ─────────────────────────
def on_price(self, sym: str, bid: float, ask: float):
with self._lock:
if not self.enabled:
# ATR-Anzeige: einmal pro Minute im Idle aktualisieren
now = time.time()
if now - self._last_idle_atr_ts > 60:
self._last_idle_atr_ts = now
else:
return
else:
self._ticks += 1
ticks = self._ticks
if not self.enabled:
tf = self._choose_atr_tf()
with mt5_lock(timeout=1) as got:
if got:
self._refresh_atr(sym, tf)
return
ticks = self._ticks
ps = self.trader.snapshot()
if ps["ticket"] is None:
with self._lock:
if self.enabled:
self.enabled = False
self._high_water = None
self._phase = ""
self._hw_at_last_modify = None
self._setup = None
self._active_tf = None
self._trade_mult = None
self._partial_done = False
log.info("Auto-Deaktiviert (Position geschlossen)")
return
is_long = (ps["order_type"] == mt5.ORDER_TYPE_BUY)
cur = bid if is_long else ask
# High-Water-Mark: immer aktualisieren, ohne MT5-Call
with self._lock:
if self._high_water is None:
self._high_water = cur
elif is_long and cur > self._high_water:
self._high_water = cur
elif not is_long and cur < self._high_water:
self._high_water = cur
hw = self._high_water
last_mod = self._last_modify_ts
# ── Früh-Ausstieg: Trade läuft ≥ _ADVERSE_EXIT_ATR×ATR gegen den Einstieg ──
# Greift auch in der Init-Phase / Warmup, damit „läuft sofort schief"-Trades
# nicht bis zum weiten Initial-Stop ausbluten.
entry = ps.get("entry_price") or 0.0
with self._lock:
atr_ae = max(self._atr, _ATR_MIN) if self._atr is not None else None
if entry and atr_ae and _ADVERSE_EXIT_ATR > 0:
adverse = (entry - cur) if is_long else (cur - entry) # >0 = gegen Position
if adverse >= _ADVERSE_EXIT_ATR * atr_ae:
lots = ps.get("lots", 0.0)
log.warning(
f"🛑 Früh-Ausstieg: {adverse:.3f}{_ADVERSE_EXIT_ATR}×ATR"
f"({atr_ae:.3f}) gegen Einstieg {entry:.3f} → Position schließen")
err = self.trader.close(reason="adverse")
if err:
log.warning(f"Früh-Ausstieg-Close fehlgeschlagen: {err}")
else:
with self._lock:
self.enabled = False
self._high_water = None
self._phase = ""
self._hw_at_last_modify = None
self._setup = None
self._active_tf = None
self._trade_mult = None
self._partial_done = False
if self._notify:
try: self._notify("adverse", lots, cur, -adverse)
except Exception as e: log.warning(f"Adverse-Notify: {e}")
return
if ticks <= _WARMUP_TICKS:
return
# Ausbruch-Erkennung: HW hat sich seit letztem Modify stark bewegt
# → adaptiver Cooldown statt fester 8-Sekunden-Pause
with self._lock:
hw_ref = self._hw_at_last_modify
atr_fb = max(self._atr, _ATR_MIN) if self._atr is not None else None
if hw_ref is not None and atr_fb is not None:
hw_delta = abs(hw - hw_ref)
breakout = hw_delta > _BREAKOUT_ATR_FACTOR * atr_fb
else:
breakout = False
effective_cooldown = _FAST_COOLDOWN_S if breakout else _MODIFY_COOLDOWN_S
if (time.time() - last_mod) < effective_cooldown:
return
with mt5_lock(timeout=2) as got:
if not got:
log.warning("MT5-Lock belegt — Trail-Tick übersprungen")
return
self._do_modify(sym, bid, ask, is_long, hw, ps, breakout=breakout)
# ── SL-Berechnung + MT5 order_send ───────────────────────────────────────
def _do_modify(self, sym: str, bid: float, ask: float,
is_long: bool, hw: float, ps: dict, breakout: bool = False):
# Eingefrorener TF/Mult vom Aktivieren — kein Wechsel mitten im Trade
with self._lock:
tf = self._active_tf
mult = self._trade_mult
if tf is None:
tf = self._choose_atr_tf()
if not self._refresh_atr(sym, tf):
return
with self._lock:
raw_atr = self._atr
atr = max(raw_atr, _ATR_MIN)
if mult is None:
mult = self._trail_mult()
if raw_atr < _ATR_MIN:
# gedrosselt (max. 1×/5 min) — lief vorher je Modify-Tick und flutete
# das Log (~35k Zeilen je 5-MB-Rotation in Niedrig-Vola-Phasen)
now_ts = time.time()
if now_ts - getattr(self, "_floor_warn_ts", 0.0) > 300:
self._floor_warn_ts = now_ts
log.warning(f"ATR({raw_atr:.4f}) < Floor({_ATR_MIN}) — Trailing nutzt Minimum")
positions = mt5.positions_get(ticket=ps["ticket"])
if not positions:
return
pos = positions[0]
si = mt5.symbol_info(sym)
if not si:
return
cur = bid if is_long else ask
entry = float(pos.price_open)
cur_sl = float(pos.sl) if pos.sl else 0.0
cur_tp = float(pos.tp) if pos.tp else 0.0
# min_dist: Broker-Mindestabstand + Spread + 5 Punkte Puffer, mindestens 1 Cent
spread = getattr(si, "spread", 0) or 0
min_dist = max((si.trade_stops_level + spread + 5) * si.point, 0.01)
profit = (cur - entry) if is_long else (entry - cur)
# ── Manuelle SL/TP-Übersteuerung erkennen (Fix 2026-07-17) ───────────────
# Hat jemand SL/TP EXTERN geändert — v. a. direkt im MT5-Terminal, wo der
# App-Pfad `deactivate()` NICHT greift — d. h. der Broker-Wert weicht von dem
# ab, was das Trailing zuletzt gesetzt hat? Dann Finger weg: Trailing abschalten,
# sonst überschreibt es die Handeingabe (real: TP 90,000 → 81,484). Erst ab dem
# 2. Modify aktiv (`_last_*` gesetzt); Reset bei Aktivierung verhindert Altwert-
# Fehlalarme. Deckt sich mit dem App-Verhalten (manuelles SL/TP = Trailing aus).
with self._lock:
last_sl = self._last_sl
last_tp = self._last_tp
tol = max(_SL_THRESHOLD_PTS * si.point, atr * _SL_THRESHOLD_ATR)
ext_sl = last_sl is not None and cur_sl and abs(cur_sl - last_sl) > tol
ext_tp = last_tp is not None and cur_tp and abs(cur_tp - last_tp) > tol
if ext_sl or ext_tp:
with self._lock:
self.enabled = False
self._high_water = None
self._phase = ""
self._hw_at_last_modify = None
self._active_tf = None
self._trade_mult = None
self._partial_done = False
log.info(f"Manuelle SL/TP-Änderung erkannt (SL {cur_sl:.{si.digits}f}"
f"{(last_sl or 0):.{si.digits}f} · TP {cur_tp:.{si.digits}f}"
f"{(last_tp or 0):.{si.digits}f}) → Trailing abgeschaltet, "
f"Handeingabe bleibt stehen")
return
# Gedrosselt (1×/30 s): lief je Modify-Tick → ~28k Zeilen je Log-Rotation,
# die 5×5-MB-Rotation deckte nur noch ~2 Tage Forensik ab (Fix 2026-07-19)
now_dbg = time.time()
if now_dbg - getattr(self, "_dbg_ts", 0.0) > 30:
self._dbg_ts = now_dbg
log.debug(
f"Trail: profit={profit:.3f} ATR={atr:.4f}×{mult:.1f}"
f" HW={hw:.3f} SL={cur_sl:.5f} phase={self._phase}"
+ (" [AUSBRUCH]" if breakout else ""))
# ── Teil-Exit / Runner: einmalig die Hälfte bei 1.5×ATR sichern ──────
# Bankt realen Gewinn ohne den Trade abzuwürgen — der Rest läuft mit
# dem weiten ATR-Trail weiter (Breakeven ist bei 1.5×ATR bereits aktiv,
# der Runner ist damit risikofrei).
with self._lock:
partial_done = self._partial_done
if _PARTIAL_TP_FRAC > 0 and not partial_done and profit >= _PARTIAL_TP_ATR * atr:
vol, info = self.trader.partial_close_position(
pos, si, _PARTIAL_TP_FRAC, "partial")
if vol > 0:
with self._lock:
self._partial_done = True
log.info(f"[Teil-Exit] {vol:.2f}L @ {info:.3f} gesichert "
f"(Profit {profit:.3f}{_PARTIAL_TP_ATR}×ATR)")
if self._notify:
try:
self._notify("partial", vol, float(info), profit)
except Exception as e:
log.warning(f"Teil-Exit-Notify: {e}")
else:
# nur einmal pro Trade versuchen, sonst Log-Spam bei vmin-Pos
with self._lock:
self._partial_done = True
log.info(f"[Teil-Exit] übersprungen: {info}")
# ── Drei Phasen (mit Ratsche: nie zurück) ────────────────────────────
if profit < _TRAIL_START_ATR * atr:
phase = "Init"
elif profit < _PHASE4_ATR * atr:
phase = "Trail"
else:
phase = "Lock"
# Phasen-Ratsche: ATR-Drift (neue Kerze) darf die Phase nicht
# zurückwerfen — sonst friert das Trailing in Init wieder ein
with self._lock:
prev_phase = self._phase
if _PHASE_RANK.get(prev_phase, -1) > _PHASE_RANK.get(phase, 0):
phase = prev_phase
# ── Time-Stop: nach _TIMESTOP_MIN Minuten noch in "Init" (nie ≥0,3×ATR
# gelaufen = Whipsaw-Opfer) → schließen statt bis SL bluten. Dank Phasen-
# Ratsche ist phase=="Init" exakt "HW-Profit erreichte nie den Trail-Start".
if phase == "Init" and _TIMESTOP_MIN > 0 and time.time() >= self._no_close_until:
age_s = time.time() - (pos.time - self.trader._broker_offset_s())
if age_s >= _TIMESTOP_MIN * 60:
lots = ps.get("lots", 0.0)
log.info(f"⏱ Time-Stop: {age_s/60:.0f} min ohne Fortschritt "
f"(Phase Init, Profit {profit:+.3f}) → Position schließen")
err = self.trader.close(reason="timestop")
if err:
log.warning(f"Time-Stop-Close fehlgeschlagen: {err}")
else:
with self._lock:
self.enabled = False
self._high_water = None
self._phase = ""
self._hw_at_last_modify = None
self._setup = None
self._active_tf = None
self._trade_mult = None
self._partial_done = False
return
if phase == "Init":
if cur_sl:
new_sl = cur_sl # Initial-SL beibehalten
else:
if is_long:
new_sl = max(entry - mult * atr, 0.001)
else:
new_sl = entry + mult * atr
elif phase == "Trail":
# Breakeven-Floor (SL = Entry) erst ab 1.0×ATR Profit — vorher
# stoppte jeder normale Rückläufer zum Entry mit ±0 aus (+0.08-Closes)
breakeven = profit >= _BREAKEVEN_ATR * atr
if is_long:
candidate = hw - mult * atr
if breakeven:
candidate = max(candidate, entry)
candidate = max(candidate, entry - mult * atr)
new_sl = max(candidate, cur_sl) if cur_sl else candidate
else:
candidate = hw + mult * atr
if breakeven:
candidate = min(candidate, entry)
candidate = min(candidate, entry + mult * atr)
new_sl = min(candidate, cur_sl) if cur_sl else candidate
else:
tight_mult = max(_PHASE4_MULT_MIN, mult * _PHASE4_MULT_SCALE)
if is_long:
new_sl = max(hw - tight_mult * atr, entry, cur_sl)
else:
candidate = hw + tight_mult * atr
new_sl = min(candidate, entry, cur_sl) if cur_sl \
else min(candidate, entry)
new_sl = min(new_sl, entry + tight_mult * atr)
# ── Mindest-Abstand zum aktuellen Kurs (inkl. Spread) ────────────────
if is_long:
new_sl = min(new_sl, cur - min_dist)
else:
new_sl = max(new_sl, cur + min_dist)
new_sl = max(round(new_sl, si.digits), 0.001)
# ── Phase speichern ───────────────────────────────────────────────────
with self._lock:
self._phase = phase
# ── Schwellwert und SL-Qualifizierung ────────────────────────────────
threshold = max(_SL_THRESHOLD_PTS * si.point, atr * _SL_THRESHOLD_ATR)
if is_long:
sl_ok = new_sl > cur_sl + threshold
else:
sl_ok = (not cur_sl) or (new_sl < cur_sl - threshold)
tp_missing = (cur_tp == 0.0)
# ── Trailing TP: kurz unter dem High-Water-Mark nachziehen ───────────
if phase == "Init":
# Phase 1: kein Trailing — Trade braucht Luft (fester Puffer vom Entry)
tp_send = entry + _TP_INIT_ATR * atr if is_long else entry - _TP_INIT_ATR * atr
elif phase == "Lock":
# Phase 3: sehr enges Trailing direkt am HW (0.3×ATR Abstand)
tp_send = hw - _TP_LOCK_ATR * atr if is_long else hw + _TP_LOCK_ATR * atr
else:
# Phase 2 (Trail): TP zieht mit HW mit (0.5×ATR unter dem Hoch)
tp_send = hw - _TP_TRAIL_ATR * atr if is_long else hw + _TP_TRAIL_ATR * atr
# Mindest-TP: 0.5×ATR über/unter Entry — nicht in Verlust schließen
if is_long:
tp_send = max(tp_send, entry + 0.5 * atr)
else:
tp_send = min(tp_send, entry - 0.5 * atr)
# Broker-Mindestabstand zum aktuellen Kurs einhalten
if is_long and tp_send <= cur + min_dist:
tp_send = cur + min_dist * 2
elif not is_long and tp_send >= cur - min_dist:
tp_send = cur - min_dist * 2
tp_send = round(tp_send, si.digits)
# TP-Ratsche nur in Init/Trail (nie zurückbewegen). In Phase Lock darf
# der TP näher an den Kurs rücken — Gewinnsicherung, sonst bleibt der
# weite Init-TP für immer stehen und das Lock-TP greift nie.
if cur_tp and phase != "Lock":
if is_long and cur_tp > tp_send:
tp_send = cur_tp
elif not is_long and cur_tp < tp_send:
tp_send = cur_tp
# TP-Trailing: signifikante Änderung auch ohne SL-Änderung senden
# (abs: in Lock zählt auch die Annäherung als Verbesserung)
tp_improvement = bool(cur_tp) and not tp_missing \
and abs(tp_send - cur_tp) > threshold
if not sl_ok and not tp_missing and not tp_improvement:
return
# Wenn nur TP verbessert: SL unverändert lassen
sl_to_send = new_sl if sl_ok else cur_sl
# ── order_send ────────────────────────────────────────────────────────
res = mt5.order_send({
"action": mt5.TRADE_ACTION_SLTP,
"symbol": sym,
"position": pos.ticket,
"sl": sl_to_send,
"tp": tp_send,
})
if res and res.retcode == mt5.TRADE_RETCODE_DONE:
with self._lock:
self._last_sl = sl_to_send
self._last_tp = tp_send
self._last_modify_ts = time.time()
self._hw_at_last_modify = hw
improvement = abs(sl_to_send - cur_sl) if cur_sl else abs(sl_to_send - entry)
tp_log = ""
if tp_missing:
tp_log = f" TP={tp_send:.{si.digits}f} (neu)"
elif tp_improvement:
tp_log = f" TP {cur_tp:.{si.digits}f}{tp_send:.{si.digits}f}"
log.info(
f"[{phase}{'*' if breakout else ''}]"
f" SL {cur_sl:.{si.digits}f}{sl_to_send:.{si.digits}f}"
f" Δ{improvement:.{si.digits}f}"
f" HW={hw:.3f} ATR={atr:.4f}×{mult:.1f}"
f" profit={profit:.3f}{tp_log}")
else:
rc = res.retcode if res else "None"
log.error(f"SLTP-Fehler rc={rc} SL={sl_to_send} TP={tp_send}")
with self._lock:
self._last_modify_ts = time.time() - _MODIFY_COOLDOWN_S + _ERROR_COOLDOWN_S
def deactivate(self) -> bool:
"""Trailing sofort abschalten (z. B. bei manuellem SL/TP durch den User —
sonst überschriebe das Trailing den Wert beim nächsten Tick). Rückgabe =
ob es vorher aktiv war."""
with self._lock:
was = self.enabled
self.enabled = False
self._high_water = None
self._ticks = 0
self._phase = ""
self._hw_at_last_modify = None
self._active_tf = None
self._trade_mult = None
self._partial_done = False
return was
# ── Toggle ────────────────────────────────────────────────────────────────
def toggle(self, sym: str) -> bool:
with self._lock:
new_state = not self.enabled
if new_state:
tf = self._choose_atr_tf()
with mt5_lock() as got:
if got:
self._refresh_atr(sym, tf)
with self._lock:
self.enabled = True
self._high_water = None
self._ticks = 0
self._phase = "Init"
# Kurze Wartezeit (2s) statt vollen Cooldown (8s) nach Aktivierung
self._last_modify_ts = time.time() - _MODIFY_COOLDOWN_S + _ACTIVATE_DELAY_S
self._hw_at_last_modify = None
# TF + Mult für den gesamten Trade einfrieren — die Auto-Wahl
# wechselte sonst mitten im Trade und warf die Phase zurück
self._active_tf = tf
self._trade_mult = self._trail_mult()
self._partial_done = False
# Referenz für die Extern-Änderungs-Erkennung neu starten — sonst
# würde der Alt-SL/-TP des Vortrades als „manuelle Änderung" gelesen
self._last_sl = None
self._last_tp = None
atr = self._atr
mult = self._trade_mult
effective_atr = max(atr, _ATR_MIN) if atr else None
log.info(
f"AKTIVIERT ATR={effective_atr:.4f} ({_TF_LABELS.get(tf, '?')})"
f" mult={mult:.1f}x" if effective_atr
else "AKTIVIERT (ATR wird beim ersten Tick berechnet)")
return True
else:
with self._lock:
self.enabled = False
self._high_water = None
self._ticks = 0
self._phase = ""
self._hw_at_last_modify = None
self._active_tf = None
self._trade_mult = None
self._partial_done = False
log.info("DEAKTIVIERT")
return False
# ── Snapshot für UI ───────────────────────────────────────────────────────
def snapshot(self) -> dict:
with self._lock:
raw_atr = self._atr
tf = self._atr_tf
override = self._atr_tf_override
return {
"enabled": self.enabled,
"atr": max(raw_atr, _ATR_MIN) if raw_atr else None,
"atr_raw": raw_atr,
"atr_tf": _TF_LABELS.get(tf, "?"),
"atr_tf_override": _TF_LABELS.get(override) if override is not None else None,
"high_water": self._high_water,
"last_sl": self._last_sl,
"last_tp": self._last_tp,
"trail_mult": self._trade_mult if self._trade_mult is not None
else self._trail_mult(),
"phase": self._phase,
}
+201
View File
@@ -0,0 +1,201 @@
"""
core/tu_rating.py — Traders-Union-Analyse (tradersunion.com)
=============================================================
Holt die technische Analyse für WTI von der öffentlichen Traders-Union-API
(quotes.tradersunion.com) — dieselben Daten, die der Tacho auf
https://tradersunion.com/currencies/forecast/wti-crude-oil/signals/ anzeigt.
API: GET /api/v3/informer/technical-analysis/detailed/?symbol=WTI/USD
Liefert pro Zeitebene (m5, m15, m30, h1, h4, d1, w1):
- forecast → Gesamt-Verdikt ("Strong Sell""Strong Buy")
- ta → Oszillator-Zähler (buy/sell/neutral, 13 Indikatoren)
- ma → Moving-Average-Zähler (MA5MA200, SMA+EMA)
- indicators → 14 Einzelindikatoren mit Werten
Auto-Trader-Signal (trade_signal):
Verdikt je TF → Score (-2 Strong Sell … +2 Strong Buy)
LONG wenn m15 ≥ +1 UND m30 ≥ +1 UND h1 nicht dagegen (≥ 0)
SHORT spiegelbildlich. Konfidenz steigt mit Strong-Verdikten und
H1-/M5-Bestätigung. Daten älter als 5 min → WARTEN (kein Stale-Trading).
Kein API-Key nötig. fetch() blockiert (HTTP) — im Hintergrund-Thread rufen;
snapshot()/trade_signal() liefern den letzten Stand sofort.
"""
from __future__ import annotations
import threading
import time
from core.logger import get_logger
log = get_logger("turating")
_API_URL = ("https://quotes.tradersunion.com/api/v3/"
"informer/technical-analysis/detailed/")
_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0 Safari/537.36")
# Zeitebenen wie auf der Website: Anzeige-Key → API-Key
INTERVALS: dict[str, str] = {
"5m": "m5",
"15m": "m15",
"30m": "m30",
"1h": "h1",
"4h": "h4",
"1d": "d1",
"1w": "w1",
}
# Verdikt → numerischer Score (für Nadel + Trading-Logik)
_VERDICT_SCORE = {
"strong sell": -2, "sell": -1, "neutral": 0, "buy": 1, "strong buy": 2,
}
_STALE_S = 300 # Daten älter als 5 min → kein Trading-Signal
def _vscore(verdict: str | None) -> int:
return _VERDICT_SCORE.get((verdict or "").strip().lower(), 0)
def _counts_rating(c: dict) -> float | None:
"""Nadel-Position -1..+1 aus Buy/Sell/Neutral-Zählern."""
total = (c.get("buy") or 0) + (c.get("sell") or 0) + (c.get("neutral") or 0)
if not total:
return None
return ((c.get("buy") or 0) - (c.get("sell") or 0)) / total
class TradersUnionProvider:
"""Holt + cached die Traders-Union-Analyse für ein Symbol."""
def __init__(self, symbol: str = "WTI/USD"):
self.symbol = symbol
self._lock = threading.Lock()
self._busy = False
self._data: dict = {} # {interval_key: {...}}
self._error: str | None = None
self._last_update: float | None = None
# ── HTTP-Fetch (blockierend — im Hintergrund-Thread aufrufen) ───────────
def fetch(self) -> bool:
with self._lock:
if self._busy:
return False
self._busy = True
try:
import requests
resp = requests.get(
_API_URL, params={"symbol": self.symbol},
headers={"User-Agent": _UA, "Accept": "application/json"},
timeout=15)
resp.raise_for_status()
payload = resp.json().get("data") or {}
data = {}
for key, api_key in INTERVALS.items():
tf = payload.get(api_key)
if not isinstance(tf, dict):
continue
osc = tf.get("ta") or {}
ma = tf.get("ma") or {}
c_osc = {"buy": osc.get("buy", 0),
"sell": osc.get("sell", 0),
"neutral": osc.get("neutral", 0)}
c_ma = {"buy": ma.get("buy", 0),
"sell": ma.get("sell", 0),
"neutral": ma.get("neutral", 0)}
total = {k: c_osc[k] + c_ma[k] for k in c_osc}
data[key] = {
"verdict": tf.get("forecast") or "",
"verdict_osc": osc.get("forecast") or "",
"verdict_ma": ma.get("forecast") or "",
"rating": _counts_rating(total),
"rating_osc": _counts_rating(c_osc),
"rating_ma": _counts_rating(c_ma),
"counts": total,
"counts_osc": c_osc,
"counts_ma": c_ma,
"score": _vscore(tf.get("forecast")),
}
if not data:
raise ValueError(f"keine TF-Daten für {self.symbol}")
with self._lock:
self._data = data
self._error = None
self._last_update = time.time()
log.info(f"{self.symbol}: " + " ".join(
f"{k}={d['verdict']}" for k, d in data.items()))
return True
except Exception as e:
with self._lock:
self._error = str(e)[:120]
log.warning(f"TU-Analyse Fetch fehlgeschlagen: {e}")
return False
finally:
with self._lock:
self._busy = False
# ── Snapshot für UI (Tachos) ─────────────────────────────────────────────
def snapshot(self) -> dict:
with self._lock:
return {
"symbol": self.symbol,
"intervals": dict(self._data),
"error": self._error,
"last_update": self._last_update,
"busy": self._busy,
}
# ── Trading-Signal für den Auto-Trader ───────────────────────────────────
def trade_signal(self) -> dict:
"""
Konsens-Signal aus der Traders-Union-Analyse:
LONG: m15 ≥ +1 und m30 ≥ +1 und h1 ≥ 0
SHORT: m15 ≤ 1 und m30 ≤ 1 und h1 ≤ 0
Konfidenz: 60 % Basis, +10 je Strong-Verdikt (m15/m30),
+10 bei H1-Bestätigung, +5 bei M5-Bestätigung (max 90).
"""
with self._lock:
data = dict(self._data)
ts = self._last_update
base = {"signal": "WARTEN", "conf_pct": 0, "score": 0.0,
"setup": "TU_KONSENS", "regime": None, "rsi": None,
"reasons": []}
if not data:
base["reasons"] = ["keine TU-Daten"]
return base
if not ts or time.time() - ts > _STALE_S:
base["reasons"] = ["TU-Daten veraltet"]
return base
sc = {k: data.get(k, {}).get("score", 0) for k in
("5m", "15m", "30m", "1h")}
reasons = [f"{k}: {data[k]['verdict']}" for k in
("5m", "15m", "30m", "1h", "4h", "1d") if k in data]
signal = "WARTEN"
if sc["15m"] >= 1 and sc["30m"] >= 1 and sc["1h"] >= 0:
signal = "LONG"
elif sc["15m"] <= -1 and sc["30m"] <= -1 and sc["1h"] <= 0:
signal = "SHORT"
conf = 0
if signal != "WARTEN":
d = 1 if signal == "LONG" else -1
# Basis 60: mit H1-Bestätigung (+10) erreicht ein sauberer
# Konsens die Auto-Schwelle (70) auch ohne Strong-Verdikt
conf = 60
conf += 10 * sum(1 for k in ("15m", "30m") if sc[k] * d >= 2)
if sc["1h"] * d >= 1:
conf += 10
if sc["5m"] * d >= 1:
conf += 5
conf = min(conf, 90)
# Score -1..+1 (Mittel der Trading-TFs, normiert auf ±2)
score = (sc["15m"] + sc["30m"] + sc["1h"]) / 6.0
return {"signal": signal, "conf_pct": conf, "score": score,
"setup": "TU_KONSENS", "regime": None, "rsi": None,
"reasons": reasons}
+786
View File
@@ -0,0 +1,786 @@
"""
core/wave_rec.py — Trend-Empfehlungsmodul (EMA-Trend)
======================================================
Signalrichtung folgt dem geglätteten Trend (EMA12 vs EMA50) der gewählten
Zeitebene — NICHT mehr dem verrauschten ZigZag-Swing. Grund: Backtests zeigten,
dass das alte Momentum-ZigZag ~0 Edge hatte (kaufte die Spitzen von Bounces),
während EMA-Trendfolge einen klaren, positiven Edge liefert.
Signal:
EMA12 > EMA50 → LONG | EMA12 < EMA50 → SHORT
|EMA12EMA50| < _TREND_DEADBAND×ATR → WARTEN (kein klarer Trend / Chop)
Kurs > _STRETCH_MAX×ATR über/unter EMA50 → WARTEN (überdehnt, kein Spät-Einstieg)
Konfidenz: 60 % Basis, +Trendstärke (EMA-Abstand in ATR), +Einstiegsqualität
(nahe der EMA = besseres CRV, kein Hinterherkaufen),
+Traders-Union-Bestätigung (5m/15m); stark gegenläufige TU blockt.
Arbeitsteilung (Thread-sicher):
set_timeframe(tf) → vom KI-Agenten gewählt (M1/M5/M15/M30/H1)
refresh_market(sym) → alle ~5 s im Hintergrund (MT5-Call: Bars + EMAs)
signal() → aktueller, fertig gefilterter rec-Dict (kein MT5)
snapshot() → Trend-Status für die Anzeige (EMA als wave_start,
Abstand zur EMA in ATR als move_atr)
"""
from __future__ import annotations
import threading
import time
from datetime import datetime
from zoneinfo import ZoneInfo
import MetaTrader5 as mt5
from core.mt5_utils import mt5_lock
from core.market_hours import session_state
from core.analysis import calc_trend_angle
from core.logger import get_logger
log = get_logger("wave")
_N_BARS = 180
_ATR_PERIOD = 14
_STALE_S = 150
_BASE_CONF = 60
_MIN_CONF = 55 # Signale < 55 % Konfidenz → WARTEN. Gemessen
# (backtest_conf.py): Band 4054 % = NEGATIVER Edge
# (0,025), ≥55 % = +0,058. Gate hebt Edge + Ertrag.
# Signalrichtung = geglätteter EMA-Trend (per Backtest mit Edge belegt; das alte
# ZigZag-Momentum hatte ~0 Edge / kaufte Spitzen). Totband filtert Chop, die
# Anti-Überdehnung verhindert Spät-Einstiege weit weg von der EMA.
_EMA_FAST = 12
_EMA_SLOW = 50
_TREND_DEADBAND = 0.15 # |EMA12 EMA50| < x×ATR → kein klarer Trend → WARTEN
_STRETCH_MAX = 3.5 # Kurs > x×ATR über/unter EMA50 → überdehnt → kein Einstieg.
# 2,5→3,5 (backtest_stretch.py): +26 % Signale (45→57 % der
# Zeit), Bänder 2,53,5 noch positiv, Gesamtertrag steigt.
# NICHT höher: ab 3,5×ATR kippt der Edge klar negativ (0,13).
_REVERSAL_STRETCH = 3.0 # Bounce/Reversal-Trigger — ENTKOPPELT von _STRETCH_MAX:
# ab |stretch|≥3,0×ATR + Winkeldrehung antizyklischer
# Einstieg (mehr Bounces als bei 3,5). Gemessen
# (backtest_bounce.py, Exit-Sim): Ø-R +0,185, PF 1,35,
# Treffer 70 %, ~1,5× mehr Bounces als 3,5. Anti-Über-
# dehnung (Trend-Spät-Sperre) bleibt bei _STRETCH_MAX=3,5.
# Volatilitäts-Squeeze-Breakout (additiver Setup, gemessen `backtest_breakout_squeeze.py`,
# 2-Stichproben-positiv & robust über Box 1020 / k 0,10,2): Box = Spanne der letzten
# _SQ_N abgeschlossenen M5-Bars; ist sie ≤ _SQ_MULT×ATR (Kompression) und bricht der
# Kurs _SQ_K×ATR über/unter die Box → Ausbruchssignal. Live-Params = die getesteten.
_SQ_N = 10 # Box-Länge (M5-Bars)
_SQ_MULT = 2.5 # Squeeze: Box ≤ _SQ_MULT×ATR = Kompression
_SQ_K = 0.1 # Ausbruch k×ATR über/unter die Box-Grenze
_SQ_REFRESH_S = 5.0 # eigener, schneller M5-Fetch NUR für den Squeeze (entkoppelt von
# der 15-s-Ampel-Drossel) → Ausbruch-Erkennung ~15 s→5 s, gleiche
# validierte M5-Regel (Variante A, 2026-07-21).
# Higher-TF-Gegen-Trend-Filter: Signal NUR, wenn der übergeordnete Trend (M30,
# EMA12 vs EMA50) nicht klar dagegen steht. Per Backtest belegt: verdoppelt den
# Ø-Edge pro Trade (+0,012 → +0,027), Treffer 52→54 %; wirft Gegen-Trend-Signale
# raus (z. B. Short im M30-Aufwärtstrend). Nur für Basis-TF < M30 aktiv.
_HTF = mt5.TIMEFRAME_M30
_HTF_LABEL = "M30"
_HTF_DEADBAND = 0.15 # |EMA12EMA50| < x×ATR(M30) → HTF gilt als neutral
# Multi-TF-Konfluenz (nur Konfidenz/Anzeige): wenn M30 UND H1 die Richtung
# bestätigen, ver-3,5-facht sich der Ø-Edge (+0,016→+0,056, backtest_improve.py).
_CONFLUENCE_BONUS = 12 # M30 + H1 beide dafür → Top-Setup (⭐⭐)
_H1_AGAINST_PEN = 8 # H1 steht gegen die Richtung (M30 ok/neutral) → schwächer
# Regressions-Winkel der Basis-TF als schnellerer Wende-Detektor gegen die
# nachlaufende EMA (backtest_angle.py: Winkel dafür/neutral +0,055 vs dagegen
# +0,025). NUR Konfidenz/Warnung — als hartes Gate senkt es den Gesamtertrag.
_ANGLE_LR = 14 # Regressionsfenster (= ANGLE_LR_BARS)
_ANGLE_DEAD = 2.0 # Totband um 90° (Grad) → darunter „neutral"
_ANGLE_BONUS = 5 # Winkel bestätigt die EMA-Richtung
_ANGLE_PENALTY = 10 # Winkel klar GEGEN die EMA-Richtung (mögliche Wende)
# Tageszeit-Gate — REAKTIVIERT 2026-07-13 (User-OK) nach Echtkosten-Messung
# (`backtest_realcosts.py`, Kosten = Bar-Spread/ATR statt pauschal 0,1):
# 07 Uhr = Nacht-Kostenfalle (konstanter Spread ÷ niedriger Nacht-ATR =
# 0,320,50×ATR Kosten → Edge sicher aufgefressen; in BEIDEN Hälften negativ).
# 12 & 16 Uhr = 3× unabhängig negativ gemessen (hourly, hourly_split, realcosts).
# Gate-Politik „07+12" verbesserte BEIDE Hälften (H1 4038→−1870, H2 +100→+1191).
# Robust positiv sind nur 2122 Uhr (US-Session). (Zwischenzeitlich war das Gate
# auf User-Wunsch ganz aus; Rückholung gezielt & gemessen, kein Pauschal-Gate.)
_DEAD_HOURS = (0, 1, 2, 3, 4, 5, 6, 7, 12, 16)
_BERLIN = ZoneInfo("Europe/Berlin")
# Hohe Vola (oberes ATR-Terzil, ~>0,27 bei WTI-M5) = schwächster/negativer Edge
# → nur Konfidenz-Abzug (Schwelle regime-abhängig, daher KEIN hartes Gate).
_ATR_HIGH = 0.27
_ATR_HIGH_PEN = 8
# S/R-Kontext (vom Engine gesetzt): Konfidenz dämpfen, wenn der Einstieg direkt
# in ein Gegen-Level läuft (wenig Raum / schlechtes CRV), anheben bei Rückenwind.
_SR_NEAR_ATR = 0.5 # "dicht an" einer Linie = innerhalb x×ATR
_SR_PENALTY = 12 # Konfidenz-Abzug: Trade läuft ins Level (wenig Raum)
_SR_BONUS = 8 # Konfidenz-Bonus: Trade startet vom Level mit Rückenwind
# Börsen-Sessions (Frankfurt 9:00 / US 15:00): Vorsicht direkt nach Open,
# Bonus während aktiver Session, Dämpfung in dünnen Zeiten.
_SESSION_CAUTION = 15 # Abzug in den ersten Minuten nach einem Open (Whipsaw)
_SESSION_BONUS = 5 # Bonus während aktiver US/DE-Session (Liquidität)
_SESSION_OFFHOURS = 8 # Abzug außerhalb DE/US-Session (dünne Liquidität)
# TradersUnion ist KEIN harter Blocker mehr (blockte zu oft starke EMA-Trends),
# sondern ein Konfidenz-Faktor je TF (5m/15m): bestätigt +Bonus, dagegen Abzug.
_TU_BONUS = 6 # je TF, das die Richtung bestätigt
_TU_PENALTY = 8 # je TF, das gegen die Richtung steht (beide = 16)
_TF_LABELS = {
mt5.TIMEFRAME_M1: "M1",
mt5.TIMEFRAME_M5: "M5",
mt5.TIMEFRAME_M15: "M15",
mt5.TIMEFRAME_M30: "M30",
mt5.TIMEFRAME_H1: "H1",
}
def _atr(highs, lows, closes, period=_ATR_PERIOD):
trs = []
for i in range(1, len(highs)):
trs.append(max(highs[i] - lows[i],
abs(highs[i] - closes[i - 1]),
abs(lows[i] - closes[i - 1])))
if not trs:
return None
return sum(trs[-period:]) / min(len(trs), period)
def _ema_last(vals, period):
"""Letzter EMA-Wert der Reihe (genügt für den Trend-Vergleich)."""
if not vals:
return None
k = 2.0 / (period + 1)
e = vals[0]
for v in vals[1:]:
e = v * k + e * (1.0 - k)
return e
def _ema_series(vals, period):
"""Komplette EMA-Reihe (für die Kreuzungs-/Wende-Erkennung je TF)."""
k = 2.0 / (period + 1)
out = []
e = vals[0] if vals else 0.0
for i, v in enumerate(vals):
e = v if i == 0 else v * k + e * (1.0 - k)
out.append(e)
return out
class WaveRecommender:
"""ATR-ZigZag-Wellen-Empfehlung, bestätigt durch Traders-Union-Daten."""
def __init__(self, tu_provider, timeframe: int = mt5.TIMEFRAME_M5):
self.tu = tu_provider
self._lock = threading.Lock()
self._tf = timeframe
self._rec: dict | None = None
self._snap: dict = {}
self._ts: float = 0.0
self._error: str | None = None
self._sr_res: float | None = None # nächster Widerstand über Preis
self._sr_sup: float | None = None # nächste Unterstützung unter Preis
self._turn_due: float = 0.0 # nächster TF-Ampel-Refresh (Throttle)
self._sq_due: float = 0.0 # nächster schneller M5-Squeeze-Fetch (~5 s)
self._dead_hours: set = set(_DEAD_HOURS) # Tageszeit-Gate (per Config setzbar)
self._last_tf_turns: dict = {} # zuletzt berechnete TF-Wenden
self._last_bounce: dict = {"state": None, "dir": None, "tf": None} # Multi-TF-Bounce
# ATR-Breakout-Bestätigung: ein Richtungssignal wird erst durchgelassen,
# wenn der Kurs k×ATR in Signalrichtung gelaufen ist (gemessen ~2× Edge,
# `backtest_breakout.py`). 0 = aus. `_pend` = laufende Bestätigung.
self._breakout_k = 1.0
self._breakout_timeout_s = 3600.0
self._pend: dict | None = None
self._squeeze: dict | None = None # Volatilitäts-Squeeze-Breakout (M5)
# Entry-Raum-Gate (gemessen `backtest_entryroom.py`, monoton in BEIDEN
# Hälften): kein Entry, wenn das Gegenlevel < X×ATR entfernt ist — der
# Ertrag ist durch den S/R-Auto-Close gedeckelt, Kosten fressen den Rest.
# 0 = aus. Nur Live-Pfad (refresh_market); Backtests via _build bleiben frei.
self._entry_room_atr = 0.0
def set_entry_room(self, x: float):
with self._lock:
self._entry_room_atr = max(0.0, float(x))
log.info(f"Entry-Raum-Gate {self._entry_room_atr:.2f}×ATR "
f"({'aus' if self._entry_room_atr <= 0 else 'aktiv'})")
def set_breakout_k(self, k: float):
with self._lock:
self._breakout_k = max(0.0, float(k))
self._pend = None
log.info(f"Breakout-Bestätigung k={self._breakout_k:.2f}×ATR "
f"({'aus' if self._breakout_k <= 0 else 'aktiv'})")
def set_dead_hours(self, hours):
"""Tageszeit-Gate setzen (Set/Liste von Stunden 023). Leer/None = AUS.
Default (Code) = gemessene Negativ-Stunden; per `[trading] dead_hours` steuerbar."""
try:
hs = {int(h) for h in (hours or []) if 0 <= int(h) <= 23}
except (TypeError, ValueError):
hs = set(_DEAD_HOURS)
with self._lock:
self._dead_hours = hs
log.info(f"Tageszeit-Gate: {sorted(hs) if hs else 'AUS'}")
def set_sr_context(self, resistance, support):
"""Vom Engine: nächste S/R-Linien über/unter dem aktuellen Preis."""
with self._lock:
self._sr_res = resistance
self._sr_sup = support
def set_timeframe(self, tf: int):
with self._lock:
self._tf = tf
self._rec = None # alte Welle verwerfen — TF gewechselt
self._ts = 0.0
self._pend = None # Breakout-Bestätigung zurücksetzen
log.info(f"Wellen-TF: {_TF_LABELS.get(tf, str(tf))}")
# ── TU als Konfidenz-Faktor (kein harter Blocker mehr) ───────────────────
def _tu_check(self, direction: str) -> tuple[bool, int, list[str]]:
"""Rückgabe (immer True, kein Block; Bonus/Abzug; Gründe). TU bestätigt
die EMA-Richtung → Bonus, steht dagegen → Abzug — pro TF (5m/15m)."""
snap = self.tu.snapshot()
iv = snap.get("intervals") or {}
s5 = iv.get("5m", {}).get("score")
s15 = iv.get("15m", {}).get("score")
if s5 is None or s15 is None:
return True, 0, ["TU: keine Daten"]
d = 1 if direction == "LONG" else -1
bonus, reasons = 0, []
for lbl, s in (("5m", s5), ("15m", s15)):
if s * d >= 1:
bonus += _TU_BONUS; reasons.append(f"TU {lbl} dafür")
elif s * d <= -1:
bonus -= _TU_PENALTY; reasons.append(f"TU {lbl} dagegen")
if not reasons:
reasons.append("TU neutral")
return True, bonus, reasons
# ── Marktdaten-Refresh (Hintergrund-Thread, ~5 s) ────────────────────────
def refresh_market(self, sym: str):
with self._lock:
tf = self._tf
with mt5_lock(timeout=2) as got:
if not got:
return
bars = mt5.copy_rates_from_pos(sym, tf, 0, _N_BARS)
# Higher-TF-Trend (M30) für den Gegen-Trend-Filter — nur für
# niedrigere Basis-TFs (M30/H1 filtern sich sonst selbst).
hbars = None
if tf not in (_HTF, mt5.TIMEFRAME_H1):
hbars = mt5.copy_rates_from_pos(sym, _HTF, 0, _N_BARS)
# H1-Trend zusätzlich für die Multi-TF-Konfluenz (nur Konfidenz).
h1bars = None
if tf != mt5.TIMEFRAME_H1:
h1bars = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_H1, 0, _N_BARS)
# Wende-Erkennung je TF (M1/M5/M15/M30) für die Welle-Ampel:
# EMA12/50-Kreuzung mit Totband. Gedrosselt (~15 s) — die Ampel
# braucht keine 5-s-Granularität, spart ~⅓ der Lock-Haltezeit.
turn_data: dict = {}
if time.time() >= self._turn_due:
for _lbl, _t in (("M1", mt5.TIMEFRAME_M1), ("M5", mt5.TIMEFRAME_M5),
("M15", mt5.TIMEFRAME_M15), ("M30", mt5.TIMEFRAME_M30),
("H1", mt5.TIMEFRAME_H1)):
# M5 tiefer holen (320): daraus werden die P(break)-Pivot-Level
# berechnet (Training: rohe M5-Pivots k=3, Lookback 300 Bars).
_n = 320 if _lbl == "M5" else 120
_tb = mt5.copy_rates_from_pos(sym, _t, 0, _n)
if _tb is not None and len(_tb) > _ATR_PERIOD:
_c = [float(b["close"]) for b in _tb]
_h = [float(b["high"]) for b in _tb]
_l = [float(b["low"]) for b in _tb]
turn_data[_lbl] = (_c, _atr(_h, _l, _c))
if _lbl == "M5":
self._m5_hl = (_h, _l)
self._turn_due = time.time() + 15
# ── Schneller M5-Squeeze-Fetch (Variante A): entkoppelt von der 15-s-
# Ampel, kleiner 60-Bar-M5-Fetch ~alle 5 s → Ausbrüche werden ~3× schneller
# erkannt (gleiche validierte M5-Regel). Squeeze wird danach neu berechnet.
sq_m5 = None
if time.time() >= self._sq_due:
_sb = mt5.copy_rates_from_pos(sym, mt5.TIMEFRAME_M5, 0, 60)
if _sb is not None and len(_sb) > _ATR_PERIOD:
sq_m5 = ([float(b["high"]) for b in _sb],
[float(b["low"]) for b in _sb],
[float(b["close"]) for b in _sb])
self._sq_due = time.time() + _SQ_REFRESH_S
if bars is None or len(bars) < _ATR_PERIOD + 5:
with self._lock:
self._error = "keine Bars"
return
highs = [float(b["high"]) for b in bars]
lows = [float(b["low"]) for b in bars]
closes = [float(b["close"]) for b in bars]
atr = _atr(highs, lows, closes)
if not atr or atr <= 0:
with self._lock:
self._error = "ATR=0"
return
cur = closes[-1]
tf_lbl = _TF_LABELS.get(tf, str(tf))
ef = _ema_last(closes, _EMA_FAST)
es = _ema_last(closes, _EMA_SLOW)
with self._lock:
sr_res, sr_sup = self._sr_res, self._sr_sup
htf_trend = self._htf_sign(hbars)
h1_trend = self._htf_sign(h1bars)
angle = calc_trend_angle(closes[-(_ANGLE_LR + 2):], _ANGLE_LR)
hour = datetime.now(_BERLIN).hour
rec, snap = self._build(ef, es, cur, atr, tf_lbl, 0,
sr_res, sr_sup, htf_trend=htf_trend,
h1_trend=h1_trend, angle=angle, hour=hour)
# Die tatsächlich genutzten Higher-TF-Signale für die Gesamtempfehlung
# exponieren (konsistent mit der Empfehlung, nicht mit dem Winkel-Maß).
snap["htf_trend"] = htf_trend
snap["h1_trend"] = h1_trend
# Squeeze aus dem schnellen (~5 s) M5-Fetch neu berechnen — entkoppelt von der
# 15-s-Ampel (Variante A). Gleiche validierte M5-Regel, nur öfter geprüft.
if sq_m5:
_h5s, _l5s, _c5s = sq_m5
_a5s = _atr(_h5s, _l5s, _c5s)
if _a5s and _a5s > 0:
self._squeeze = self._squeeze_one(_h5s, _l5s, _c5s, max(_a5s, 0.12))
if turn_data: # frisch geholt → neu berechnen, sonst letzten Stand halten
self._last_tf_turns = {lbl: self._turn_state(c, a)
for lbl, (c, a) in turn_data.items()}
# P(break)-Features IMMER aus M5 (Modell wurde auf M5 trainiert/kalibriert
# — die Wave-TF wechselt per Heuristik bis M30; damit wäre mom6 ein 3-h-
# statt 30-min-Momentum und der ATR 23× größer → P(break) völlig falsch
# skaliert. Bug gefixt 2026-07-13.) ATR-Floor 0,12 wie im Training.
if "M5" in turn_data:
_c5, _a5 = turn_data["M5"]
if _a5 and _a5 > 0 and len(_c5) >= _EMA_SLOW + 2:
_a5f = max(_a5, 0.12)
self._pb_feats = {
"mom6": round((_c5[-1] - _c5[-7]) / _a5f, 3),
"mom3": round((_c5[-1] - _c5[-4]) / _a5f, 3),
"ema_diff": round((_ema_last(_c5, _EMA_FAST)
- _ema_last(_c5, _EMA_SLOW)) / _a5f, 3),
"atr": round(_a5f, 4),
}
# Pivot-Level trainingsgleich: rohe M5-Pivots (k=3) über die
# letzten ~300 Bars — NICHT die geclusterten M15-Level (die sind
# in frischen Trends oft LEER → Hint verschwand; Bugfix).
_h5, _l5 = getattr(self, "_m5_hl", (None, None))
if _h5 and len(_h5) >= 10:
_K = 3
ph = [round(_h5[j], 3) for j in range(_K, len(_h5) - _K)
if _h5[j] == max(_h5[j - _K:j + _K + 1])]
pl = [round(_l5[j], 3) for j in range(_K, len(_l5) - _K)
if _l5[j] == min(_l5[j - _K:j + _K + 1])]
self._pb_levels = {"ph": ph, "pl": pl}
# (Squeeze läuft jetzt auf dem schnellen ~5-s-M5-Fetch oben,
# nicht mehr hier auf dem 15-s-Ampel-Pfad — Variante A 2026-07-21.)
# Multi-TF-Bounce: stärksten Status über M1/M5/M15/M30 wählen (H1 ist NUR
# für die Ampel im turn_data, NICHT im gemessen-neutralen Bounce → skip).
cands = []
for lbl, (c, a) in turn_data.items():
if lbl == "H1":
continue
b = self._bounce_one(c, a)
if b:
b["tf"] = lbl; cands.append(b)
if cands:
rank = {"active": 2, "expected": 1}
best = max(cands, key=lambda b: (rank[b["state"]], b["stretch"]))
self._last_bounce = {"state": best["state"], "dir": best["dir"], "tf": best["tf"]}
else:
self._last_bounce = {"state": None, "dir": None, "tf": None}
snap["tf_turns"] = dict(self._last_tf_turns)
snap["bounce"] = dict(self._last_bounce) # Multi-TF überschreibt Basis-TF
snap["pb_feats"] = dict(self._pb_feats) if getattr(self, "_pb_feats", None) else None
snap["pb_levels"] = dict(self._pb_levels) if getattr(self, "_pb_levels", None) else None
snap["squeeze"] = dict(self._squeeze) if getattr(self, "_squeeze", None) else \
{"state": None, "dir": None, "level": None, "box_atr": None}
# ── Entry-Raum-Gate (gemessen `backtest_entryroom.py`, beide Hälften
# monoton): Richtungssignal → WARTEN, wenn das GEGENLEVEL (M5-Pivot in
# Trade-Richtung, trainingsgleich zum S/R-Auto-Close) näher als
# `_entry_room_atr`×ATR_M5 liegt — der Ertrag ist dort durch den Auto-
# Close gedeckelt (63 % Containment), die Echtkosten fressen den Rest.
# Verhindert die Sofort-/Klein-Close-Trades an der QUELLE (Exit bleibt).
rec = self._room_gate(rec, cur)
rec = self._confirm_breakout(rec, cur, atr, snap) # k×ATR-Bestätigung
with self._lock:
self._rec = rec
self._snap = snap
self._ts = time.time()
self._error = None
@staticmethod
def _htf_sign(hbars) -> int:
"""Vorzeichen des Higher-TF-Trends (M30, EMA12 vs EMA50): +1 auf,
1 ab, 0 neutral/keine Daten (Totband _HTF_DEADBAND×ATR). Fail-open:
ohne Bars → 0 → kein Filter."""
if hbars is None or len(hbars) < _EMA_SLOW + 5:
return 0
h = [float(b["high"]) for b in hbars]
l = [float(b["low"]) for b in hbars]
c = [float(b["close"]) for b in hbars]
atr = _atr(h, l, c)
if not atr or atr <= 0:
return 0
ef = _ema_last(c, _EMA_FAST)
es = _ema_last(c, _EMA_SLOW)
if ef is None or es is None:
return 0
d = ef - es
if abs(d) < _HTF_DEADBAND * atr:
return 0
return 1 if d > 0 else -1
@staticmethod
def _turn_state(closes, atr: float | None = None) -> dict:
"""Wende-Status einer TF aus der EMA12/50-Kreuzung — mit Totband, damit
bei seitwärts laufenden EMAs nicht jede Mikro-Kreuzung als „Wende" blinkt.
dir: +1 auf / 1 ab / 0 unklar (im Totband) · bars_ago: Bars seit letzter
Kreuzung (None = keine im Fenster) · fresh: echte Kreuzung ≤3 Bars her."""
if not closes or len(closes) < _EMA_SLOW + 5:
return {"dir": 0, "bars_ago": None, "fresh": False}
ef = _ema_series(closes, _EMA_FAST)
es = _ema_series(closes, _EMA_SLOW)
diff = [a - b for a, b in zip(ef, es)]
dead = (_TREND_DEADBAND * atr) if atr else 0.0 # Substanz-Schwelle
last = diff[-1]
cur = 1 if last > dead else -1 if last < -dead else 0
bars_ago = None
for i in range(len(diff) - 1, _EMA_SLOW, -1): # Warmup-Bereich überspringen
if diff[i] != 0 and (diff[i] > 0) != (diff[i - 1] > 0):
bars_ago = (len(diff) - 1) - i # Kreuzung bei Bar i
break
return {"dir": cur, "bars_ago": bars_ago,
"fresh": bars_ago is not None and bars_ago <= 3 and cur != 0}
def _confirm_breakout(self, rec, cur, atr, snap):
"""ATR-Breakout-Bestätigung (stateful): ein Richtungssignal wird erst
durchgelassen, wenn der Kurs **k×ATR in Signalrichtung** gelaufen ist
(= „X dynamisch"). Läuft er vorher k×ATR DAGEGEN oder Timeout → neu
verankern (WARTEN). Gemessen ~2× Edge/Trade (`backtest_breakout.py`).
k=0 → Filter aus (Sofort-Einstieg)."""
k = self._breakout_k
sig = rec.get("signal")
snap["breakout"] = {"pending": False, "dir": None, "need": None}
if not k or k <= 0 or atr <= 0 or sig == "WARTEN":
if sig == "WARTEN":
self._pend = None
return rec
d = 1 if sig == "LONG" else -1
p = self._pend
if p is None or p.get("dir") != d: # frisches Signal → verankern
p = {"dir": d, "level": cur + d * k * atr, "invalid": cur - d * k * atr,
"confirmed": False, "start": time.time()}
self._pend = p
if p["confirmed"]:
return rec # schon bestätigt → durchlassen
broke = (cur >= p["level"]) if d > 0 else (cur <= p["level"])
against = (cur <= p["invalid"]) if d > 0 else (cur >= p["invalid"])
if broke:
p["confirmed"] = True
return rec
if against or (time.time() - p["start"]) > self._breakout_timeout_s:
p = {"dir": d, "level": cur + d * k * atr, "invalid": cur - d * k * atr,
"confirmed": False, "start": time.time()} # neu verankern
self._pend = p
need = abs(p["level"] - cur)
snap["breakout"] = {"pending": True, "dir": sig,
"level": round(p["level"], 3), "need": round(need, 3)}
return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, "setup": "WAVE",
"regime": None, "rsi": None,
"reasons": [f"warte auf Breakout (+{k:.1f}×ATR {sig}, noch {need:.2f})"]}
def _room_gate(self, rec, cur):
"""Entry-Raum-Gate: Signal → WARTEN, wenn das Gegenlevel < X×ATR_M5 entfernt
ist (gemessen `backtest_entryroom.py`: Raum <0,6×ATR in BEIDEN Hälften klar
negativ — 7982 % WR, aber PF<1 = Klein-Close-Falle). Level/ATR trainings-
gleich aus `_pb_levels`/`_pb_feats` (M5). Fail-open: ohne Daten kein Gate."""
x = self._entry_room_atr
sig = rec.get("signal")
if x <= 0 or sig == "WARTEN":
return rec
lv = getattr(self, "_pb_levels", None) or {}
pf = getattr(self, "_pb_feats", None) or {}
atr5 = pf.get("atr")
if not atr5:
return rec
d = 1 if sig == "LONG" else -1
if d > 0:
cands = [p for p in (lv.get("ph") or []) if p > cur]
lvl = min(cands) if cands else None
else:
cands = [p for p in (lv.get("pl") or []) if p < cur]
lvl = max(cands) if cands else None
if lvl is None: # freie Bahn (bestes gemessenes Segment)
return rec
dist = (lvl - cur) * d / atr5
if dist >= x:
return rec
return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0, "setup": "WAVE",
"regime": None, "rsi": None,
"reasons": [f"kein Raum: {'Widerstand' if d > 0 else 'Support'} "
f"{lvl:.2f} nur {dist:.2f}×ATR entfernt (Gate {x:.1f}) — "
f"Ertrag gedeckelt, Kosten fressen den Edge"]}
@staticmethod
def _squeeze_one(highs, lows, closes, atr):
"""Volatilitäts-Squeeze-Breakout (M5, gemessen `backtest_breakout_squeeze.py`).
Box = Spanne der letzten _SQ_N ABGESCHLOSSENEN Bars. Ist sie ≤ _SQ_MULT×ATR
(Kompression):
- Kurs bricht _SQ_K×ATR über/unter die Box → `active` (LONG/SHORT), level =
Ausbruchsgrenze — das getestete Einstiegssignal.
- sonst → `armed` (komprimiert, Ausbruch steht bevor; dir = nähere Grenze).
Nicht komprimiert → state None. Rein transient (der Ausbruch weitet die Box →
Signal klärt sich von selbst, sobald der Move läuft)."""
none = {"state": None, "dir": None, "level": None, "box_atr": None}
if not atr or atr <= 0 or len(closes) < _SQ_N + 2:
return none
hb = highs[-_SQ_N - 1:-1]; lb = lows[-_SQ_N - 1:-1] # N abgeschlossene Bars
if not hb or not lb:
return none
box_hi = max(hb); box_lo = min(lb)
box_atr = round((box_hi - box_lo) / atr, 2)
if box_atr > _SQ_MULT: # keine Kompression
return {"state": None, "dir": None, "level": None, "box_atr": box_atr}
px = closes[-1]
up = box_hi + _SQ_K * atr; dn = box_lo - _SQ_K * atr
if px >= up:
return {"state": "active", "dir": "LONG", "level": round(up, 3), "box_atr": box_atr}
if px <= dn:
return {"state": "active", "dir": "SHORT", "level": round(dn, 3), "box_atr": box_atr}
dir_ = "LONG" if (up - px) <= (px - dn) else "SHORT" # armed → nähere Grenze
return {"state": "armed", "dir": dir_,
"level": round(up if dir_ == "LONG" else dn, 3), "box_atr": box_atr}
@staticmethod
def _bounce_one(closes, atr):
"""Bounce-Status EINER TF: überdehnt (|stretch|≥_REVERSAL_STRETCH von EMA50)
→ expected; + Winkel gedreht → active. dir=LONG bei überverkauft (unter EMA),
SHORT bei überkauft. None, wenn nicht überdehnt."""
if not closes or len(closes) < _EMA_SLOW + 5 or not atr or atr <= 0:
return None
es = _ema_series(closes, _EMA_SLOW)[-1]
stretch = (closes[-1] - es) / atr
if abs(stretch) < _REVERSAL_STRETCH:
return None
ad = calc_trend_angle(closes[-(_ANGLE_LR + 2):], _ANGLE_LR) - 90.0
turned = (stretch < 0 and ad >= _ANGLE_DEAD) or (stretch > 0 and ad <= -_ANGLE_DEAD)
return {"state": "active" if turned else "expected",
"dir": "LONG" if stretch < 0 else "SHORT", "stretch": abs(stretch)}
def _build(self, ef, es, cur, atr, tf_lbl, n_pivots,
sr_res=None, sr_sup=None, htf_trend=0, h1_trend=0, angle=90.0,
hour=None):
"""Signalrichtung aus dem EMA12/50-Trend; Einstieg nur, wenn der Trend
klar ist (Totband) und der Kurs nicht überdehnt von der EMA weg ist."""
diff = (ef - es) if (ef is not None and es is not None) else 0.0
sep = diff / atr if atr else 0.0 # Trendstärke in ATR
stretch = (cur - es) / atr if (es is not None and atr) else 0.0
snap = {"tf": tf_lbl, "atr": atr,
"direction": ("up" if diff > 0 else "down" if diff < 0 else None),
"wave_start": round(es, 3) if es is not None else None,
"move_atr": round(stretch, 2), "n_pivots": n_pivots,
"trend_sep": round(sep, 2)}
# ── Bounce-Status (für die Anzeige), unabhängig vom Signal-Flow:
# "expected" = überdehnt (≥_REVERSAL_STRETCH), Winkel noch NICHT gedreht
# "active" = überdehnt UND Winkel gedreht (= der Reversal-Trigger feuert)
# dir = LONG bei überverkauft (unter EMA), SHORT bei überkauft.
_ad = angle - 90.0
if abs(stretch) >= _REVERSAL_STRETCH:
_turned = (stretch < 0 and _ad >= _ANGLE_DEAD) or (stretch > 0 and _ad <= -_ANGLE_DEAD)
snap["bounce"] = {"state": "active" if _turned else "expected",
"dir": "LONG" if stretch < 0 else "SHORT"}
else:
snap["bounce"] = {"state": None, "dir": None}
wait = {"signal": "WARTEN", "conf_pct": 0, "score": 0.0,
"setup": "WAVE", "regime": None, "rsi": None, "reasons": []}
# Totband: kein klarer Trend → kein Trade (Chop)
if abs(sep) < _TREND_DEADBAND:
wait["reasons"] = [f"kein klarer Trend ({tf_lbl}, EMA-Abstand {sep:+.2f}×ATR)"]
return wait, snap
# Tageszeit-Gate (per Config `set_dead_hours`; Default _DEAD_HOURS = Nacht-
# Kostenfalle + 12/16 Uhr. Leer = AUS, User-Vorgabe 2026-07-22 trotz Messung).
if hour is not None and hour in self._dead_hours:
why = "Nacht-Spread frisst den Edge" if hour <= 7 else "gemessen negativer Edge"
wait["reasons"] = [f"Zeit-Gate {hour}:00 Uhr — {why}, kein Trade"]
return wait, snap
# EIA-Blackout (gemessen, `backtest_events.py`): Mittwoch 15:3016:30 Berlin
# = Vorlauf der EIA-Lagerdaten (16:30). In BEIDEN History-Hälften netto
# negativ (0,147/0,088 vs rest) — Positionierungs-Chop vor den Zahlen.
# Kausales Event-Fenster, kein Pauschal-Gate. Nur im Live-Pfad (hour≠None).
if hour is not None:
_now_b = datetime.now(_BERLIN)
if _now_b.weekday() == 2 and (15, 30) <= (_now_b.hour, _now_b.minute) < (16, 30):
wait["reasons"] = ["EIA-Blackout Mi 15:3016:30 — Lagerdaten-Vorlauf, "
"gemessen negativ, kein Trade"]
return wait, snap
sig = "LONG" if diff > 0 else "SHORT"
reversal = False
# ── Reversal/BOUNCE (antizyklisch, markiertes ZWEITsignal): überdehnt
# (|stretch|≥_REVERSAL_STRETCH=3,0) + Regressions-Winkel hat GEDREHT →
# Einstieg in Winkel-Richtung, gegen die EMA. Exit-Sim (backtest_bounce.py):
# Ø-R +0,185, PF 1,35, Treffer 70 %, Worst 2×ATR (SL-gedeckelt) — profitabel,
# aber schwächer als Trend & regime-anfällig (blutet in starken Trends).
# Hebt Anti-Überdehnung UND M30-Filter bewusst auf (per Definition gegen
# die nachlaufende EMA/HTF).
ad = angle - 90.0
if stretch <= -_REVERSAL_STRETCH and ad >= _ANGLE_DEAD:
sig, reversal = "LONG", True # überverkauft + Winkel auf → Bounce
elif stretch >= _REVERSAL_STRETCH and ad <= -_ANGLE_DEAD:
sig, reversal = "SHORT", True # überkauft + Winkel ab → Bounce
d_sig = 1 if sig == "LONG" else -1
if not reversal:
# Higher-TF-Gegen-Trend-Filter: kein Short im M30-Aufwärtstrend (und
# umgekehrt). Per Backtest belegt (Ø-Edge ×2). Nur reguläre Trendsignale.
if htf_trend != 0 and htf_trend != d_sig:
wait["reasons"] = [
f"gegen {_HTF_LABEL}-Trend "
f"({'auf' if htf_trend > 0 else 'ab'}) — kein Gegen-Trade"]
return wait, snap
# Anti-Überdehnung: nicht weit weg von der EMA hinterherkaufen/-shorten
if sig == "LONG" and stretch > _STRETCH_MAX:
wait["reasons"] = [f"überdehnt: {stretch:+.1f}×ATR über EMA — kein Spät-Long"]
return wait, snap
if sig == "SHORT" and stretch < -_STRETCH_MAX:
wait["reasons"] = [f"überdehnt: {stretch:+.1f}×ATR unter EMA — kein Spät-Short"]
return wait, snap
if reversal:
reasons = [f"🔄 Reversal {sig}: {abs(stretch):.1f}×ATR überdehnt + Winkel gedreht"]
else:
reasons = [f"{tf_lbl}-Trend {'auf' if sig == 'LONG' else 'ab'} "
f"(EMA-Abstand {sep:+.2f}×ATR)"]
conf = _BASE_CONF
# Trendstärke
if abs(sep) >= 0.5:
conf += 10; reasons.append("starker Trend")
elif abs(sep) >= 0.25:
conf += 5
# Einstiegsqualität nach gemessenem Edge (backtest_pullback.py): ein
# TIEFER Pullback (Kurs durch die EMA zurück, near<0) trägt mit Abstand
# am besten; die laue Zone direkt an der EMA (00,3) ist der schwächste
# Edge; weit gelaufenes Trend-Momentum (12×ATR) trägt wieder ordentlich.
# Beeinflusst nur Konfidenz/Score (Anzeige + Auto-Dry-Run), NICHT die
# Signalrichtung.
near = stretch if sig == "LONG" else -stretch
if near < 0:
conf += 15; reasons.append("⭐ tiefer Pullback (bestes CRV)")
elif near < 0.3:
conf -= 3; reasons.append("laue Zone an EMA (schwächster Edge)")
elif near < 1.0:
conf += 3
elif near < 2.0:
conf += 8; reasons.append("Trend-Momentum trägt")
else:
conf += 5; reasons.append("weit gelaufen — Vorsicht Überdehnung")
# ── Multi-TF-Konfluenz (nur Konfidenz): M30 UND H1 dafür = Top-Setup;
# H1 dagegen = schwächer. M30-Gegen-Trend ist oben schon WARTEN, hier
# ist htf_trend also nur =Richtung oder neutral. (Backtest: Edge ×3,5.)
if htf_trend == d_sig and h1_trend == d_sig:
conf += _CONFLUENCE_BONUS
reasons.append("⭐⭐ Konfluenz M30+H1")
elif h1_trend != 0 and h1_trend != d_sig:
conf -= _H1_AGAINST_PEN
reasons.append("H1 gegen Richtung — schwächeres Setup")
# ── Regressions-Winkel vs nachlaufende EMA (nur Konfidenz/Warnung) ───
# Steht der Winkel der Basis-TF klar GEGEN die EMA-Richtung, ist die EMA
# evtl. am Nachlaufen (Wende) → Warnung. Backtest: Winkel-dafür trägt
# klar besser; als Gate aber Gesamtertrag-negativ → nur Konfidenz.
# (ad = angle 90 wurde oben in der Reversal-Prüfung bereits berechnet.)
if (d_sig > 0 and ad < -_ANGLE_DEAD) or (d_sig < 0 and ad > _ANGLE_DEAD):
conf -= _ANGLE_PENALTY
reasons.append("⚠ Winkel gegen EMA (mögliche Wende)")
elif (d_sig > 0 and ad > _ANGLE_DEAD) or (d_sig < 0 and ad < -_ANGLE_DEAD):
conf += _ANGLE_BONUS
reasons.append("Winkel bestätigt")
# Hohe Vola dämpfen (oberes ATR-Terzil = schwächster/negativer Edge) —
# nur Konfidenz (Schwelle regime-abhängig, kein hartes Gate).
if atr and atr >= _ATR_HIGH:
conf -= _ATR_HIGH_PEN
reasons.append("hohe Vola — schwächster Edge")
# ── S/R-Kontext: Raum bis zur nächsten Linie in Trade-Richtung ───────
if atr:
thr = _SR_NEAR_ATR * atr
if sig == "LONG":
if sr_res is not None and 0 <= (sr_res - cur) < thr:
conf -= _SR_PENALTY
reasons.append(f"dicht unter Widerstand {sr_res:.3f} (wenig Raum)")
if sr_sup is not None and 0 <= (cur - sr_sup) < thr:
conf += _SR_BONUS
reasons.append(f"an Unterstützung {sr_sup:.3f} (Rückenwind)")
else: # SHORT
if sr_sup is not None and 0 <= (cur - sr_sup) < thr:
conf -= _SR_PENALTY
reasons.append(f"dicht über Unterstützung {sr_sup:.3f} (wenig Raum)")
if sr_res is not None and 0 <= (sr_res - cur) < thr:
conf += _SR_BONUS
reasons.append(f"an Widerstand {sr_res:.3f} (Rückenwind)")
# ── Börsen-Session: Vorsicht nach Open, Bonus in aktiver Session ─────
ss = session_state()
if ss["just_opened"]:
conf -= _SESSION_CAUTION
reasons.append(f"{ss['just_opened']}-Open frisch — volatil, Vorsicht")
elif ss["active"]:
conf += _SESSION_BONUS
reasons.append(f"{'+'.join(ss['active'])}-Session aktiv")
elif not ss["weekend"]:
conf -= _SESSION_OFFHOURS
reasons.append("außerhalb DE/US-Session (dünn)")
# TU aus der Empfehlung ENTFERNT (User-Vorgabe 2026-07-06): Standard-Indikator-
# Konfluenz = kein Edge (gemessen `backtest_confluence.py`); TU lagt (5m „Strong
# Buy" während 4h/1d „Strong Sell") + ist nicht backtestbar (Live-Scrape, keine
# History). `_tu_check`/`_TU_*` bleiben als Code, fließen aber NICHT mehr in die
# Empfehlungs-Konfidenz. TU ist nur noch reine Anzeige (Snapshot).
conf = max(0, min(conf, 90))
# Mindest-Konfidenz-Gate: schwache Setups (zu viele Strafen gestapelt)
# tragen negativen Edge (gemessen Band 4054 %) → kein Trade.
if conf < _MIN_CONF:
wait["reasons"] = [f"Konfidenz {conf}% < {_MIN_CONF}% — Setup zu schwach"] + reasons[:2]
return wait, snap
if reversal:
setup = "WAVE_REV_LONG" if sig == "LONG" else "WAVE_REV_SHORT"
else:
setup = "WAVE_LONG" if sig == "LONG" else "WAVE_SHORT"
rec = {"signal": sig, "conf_pct": conf,
"score": 0.8 if sig == "LONG" else -0.8,
"setup": setup, "regime": None, "rsi": None, "reasons": reasons}
return rec, snap
# ── Signal für Panel + Auto-Trader ───────────────────────────────────────
def signal(self) -> dict:
with self._lock:
rec, ts, err = self._rec, self._ts, self._error
if rec is None:
return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0,
"setup": "WAVE", "regime": None, "rsi": None,
"reasons": [err or "keine Wellen-Daten"]}
if not ts or time.time() - ts > _STALE_S:
return {"signal": "WARTEN", "conf_pct": 0, "score": 0.0,
"setup": "WAVE", "regime": None, "rsi": None,
"reasons": ["Wellen-Daten veraltet"]}
return dict(rec)
def snapshot(self) -> dict:
with self._lock:
d = dict(self._snap)
d["error"] = self._error
d["last_update"] = self._ts
return d