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